diff --git a/docs/profiles.md b/docs/profiles.md index 15f878e30..222c0a576 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -45,6 +45,36 @@ add the agent again from the managed-agent profile. Older unguarded invitation records can be promoted to guarded intent when reused through that profile flow. Remove from outbox does not revoke an invitation already dispatched to the relay. +## Agent identity + +For an identity with an agent hint (below), the Info tab adds a **Managed by** +row, matching Buzz desktop. It shows only a verified owner and is otherwise +absent: + +- The owner comes from the NIP-OA `auth` tag on the identity's own winning + signature-verified kind 0 (newest `created_at`, lower id on ties). A + session-owned `session.observe` view supplies that event: it merges live + events, refreshes on reconnect and resets on purge. While that view is live, + the profile directory's retained signed head also seeds that choice, so + reopening the pane after cache eviction never accepts an older response than + the profile the session already shows. The directory notifies subscribers + when that head changes even if display fields do not, so a head restored + from disk also updates the pane. Verification is bound to that exact event + id, so an auth-only change or a lagging older read never keeps or restores a + previous owner. It requires exactly one tag, owner ≠ agent, conditions + evaluated against the event, and a valid BIP-340 signature over + `nostr:agent-auth::`. +- The row shows the owner's name (`formatPublicKey` without a profile name), + with "(you)" when the viewer is the owner. It opens the owner's profile in + the same slot when the host can open it. +- A missing or invalid tag, a failed read, or no available view shows no row. + Without a view nothing can signal an auth-only change, so the retained head is + not trusted; reopening the profile retries. The existing `isAgent` shape + check, avatar shape and local library never supply an owner. + +Agent type and capabilities are not shown: buzz-app has no reader or contract +for their source (old Buzz kind 10100). This row has no controls. + ## Boundaries - Shared message UI recognizes author-avatar targets and identity-bound mentions. diff --git a/src/bundled/profiles/ProfileAgentIdentity.test.tsx b/src/bundled/profiles/ProfileAgentIdentity.test.tsx new file mode 100644 index 000000000..ef0ad1a7b --- /dev/null +++ b/src/bundled/profiles/ProfileAgentIdentity.test.tsx @@ -0,0 +1,624 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { createHash } from "node:crypto"; +import { schnorr } from "@noble/curves/secp256k1.js"; +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { bytesToHex } from "nostr-tools/utils"; +import { StrictMode } from "react"; +import { afterEach, expect, it, vi } from "vitest"; +import { attestedOwner } from "../../features/agents/owner-attestation"; +import { profileTarget } from "../../features/profiles/target"; +import type { ReadFilter, RelayEvent } from "../../features/relay/events"; +import type { LiveCallbacks } from "../../features/relay/live"; +import type { RelayData } from "../../features/relay/service"; +import { + createRelaySession, + type RelaySession, +} from "../../features/relay/session"; +import type { + HeadPersistence, + SavedHead, +} from "../../features/relay/persistence"; +import { + bounds, + keypair, + type Key, + message, + metadata, + profile, + roster, + signed, +} from "../../features/relay/testing"; +import { formatPublicKey } from "../../shared/identity/public-key"; +import { ProfilePanel } from "./ProfilePanel"; + +vi.mock("../../features/agents/owner-attestation", async (original) => { + const actual = + await original(); + return { attestedOwner: vi.fn(actual.attestedOwner) }; +}); +const verify = vi.mocked(attestedOwner); +/** Completion barrier: verification of exactly `event` (after call `since`) has + * finished and React has committed its result. */ +async function verifiedFor(event: RelayEvent, since = 0) { + await waitFor(() => + expect( + verify.mock.calls + .slice(since) + .some(([seen]) => (seen as RelayEvent).id === event.id), + ).toBe(true), + ); + await act(async () => { + await Promise.all( + verify.mock.results.slice(since).map((result) => result.value), + ); + }); +} +const identityRegion = () => + screen.queryByRole("region", { name: "Agent identity" }); + +const relayKey = keypair(); +const owners: { dispose(): void }[] = []; +afterEach(() => { + cleanup(); + verify.mockClear(); + for (const owner of owners.splice(0)) owner.dispose(); +}); + +function auth(agent: Key, owner: Key, sign = owner) { + const digest = new Uint8Array( + createHash("sha256").update(`nostr:agent-auth:${agent.pubkey}:`).digest(), + ); + return [ + "auth", + owner.pubkey, + "", + bytesToHex(schnorr.sign(digest, sign.secret)), + ]; +} +function agentProfile(agent: Key, tags: string[][]) { + return signed(agent, { + kind: 0, + content: JSON.stringify({ name: "Helper" }), + tags, + }); +} +function mount( + target: Key, + respond: (filter: ReadFilter) => RelayEvent[], + { + wrap, + library, + viewer = keypair().pubkey, + }: { + wrap?: (session: RelaySession) => RelaySession; + library?: string; + viewer?: string; + } = {}, +) { + const query = vi.fn(async (filters: readonly ReadFilter[]) => + filters.flatMap((filter) => respond(filter)), + ); + const owner = createRelaySession({ + viewer, + relayAuthor: relayKey.pubkey, + scope: "wss://relay.example.test", + query, + media: () => undefined, + ...(library + ? { + readAgentLibrary: async () => ({ + definitions: [], + identities: [{ pubkey: library, name: "Library agent" }], + }), + } + : {}), + subscribe: () => ({ update() {}, retry() {}, dispose() {} }), + }); + owners.push(owner); + if (library) void owner.session.agentLibrary.refresh(); + const snapshot = { + status: "ready" as const, + generation: 1, + viewer, + session: wrap ? wrap(owner.session) : owner.session, + }; + const relay: RelayData = { + snapshot: () => snapshot, + subscribe: () => () => {}, + retry() {}, + disconnect() {}, + clearCache: async () => {}, + }; + const open = vi.fn(() => true); + render( + {}} + context={{ channelId: "c", canOpen: () => true, open }} + />, + ); + return { query, open }; +} +const kind0 = (filter: ReadFilter) => filter.kinds?.includes(0); + +it("shows a verified owner as Managed by with profile ingress", async () => { + const agent = keypair(); + const owner = keypair(); + const { open } = mount(agent, (filter) => + filter.authors?.includes(owner.pubkey) + ? [profile(owner, { name: "Owner Olivia" })] + : kind0(filter) + ? [agentProfile(agent, [auth(agent, owner)])] + : [], + ); + const ownerLink = await screen.findByRole("button", { + name: "Open owner profile: Owner Olivia", + }); + expect(identityRegion()).toHaveTextContent("Managed byOwner Olivia"); + expect(identityRegion()).not.toHaveTextContent("(you)"); + await userEvent.setup().click(ownerLink); + expect(open).toHaveBeenCalledWith(profileTarget(owner.pubkey)); +}); + +it("marks the owner as you when the viewer owns the agent", async () => { + const agent = keypair(); + const owner = keypair(); + mount( + agent, + (filter) => + filter.authors?.includes(owner.pubkey) + ? [profile(owner, { name: "Owner Olivia" })] + : kind0(filter) + ? [agentProfile(agent, [auth(agent, owner)])] + : [], + { viewer: owner.pubkey }, + ); + await screen.findByRole("button", { + name: "Open owner profile: Owner Olivia (you)", + }); + expect(identityRegion()).toHaveTextContent("Managed byOwner Olivia (you)"); +}); + +it("does not trust a well-formed attestation with an invalid signature", async () => { + const agent = keypair(); + const owner = keypair(); + const forged = agentProfile(agent, [auth(agent, owner, keypair())]); + mount(agent, (filter) => (kind0(filter) ? [forged] : [])); + await verifiedFor(forged); + expect(identityRegion()).toBeNull(); + expect(screen.queryByRole("button", { name: /owner profile/ })).toBeNull(); +}); + +it("adds no agent section or reads for a profile without an agent hint", async () => { + const person = keypair(); + const { query } = mount(person, (filter) => + kind0(filter) ? [profile(person, { name: "Person" })] : [], + ); + await screen.findByRole("heading", { name: "Person" }); + expect(screen.queryByRole("region", { name: "Agent identity" })).toBeNull(); + expect(query).toHaveBeenCalledTimes(1); +}); + +function timedProfile( + agent: Key, + tags: string[][], + time: number, + name = "Helper", +) { + return signed(agent, { + kind: 0, + content: JSON.stringify({ name, is_agent: true }), + tags, + created_at: time, + }); +} +function live(agent: Key, first: RelayEvent) { + let latest = first; + let callbacks!: LiveCallbacks; + const query = vi.fn(async (filters: readonly ReadFilter[]) => + filters.flatMap((filter) => + kind0(filter) && filter.authors?.includes(agent.pubkey) ? [latest] : [], + ), + ); + const viewer = keypair().pubkey; + const owner = createRelaySession({ + viewer, + relayAuthor: relayKey.pubkey, + scope: "wss://relay.example.test", + query, + media: () => undefined, + subscribe: (next) => { + callbacks = next; + return { update() {}, retry() {}, dispose() {} }; + }, + }); + owners.push(owner); + const snapshot = { + status: "ready" as const, + generation: 1, + session: owner.session, + }; + const relay: RelayData = { + snapshot: () => snapshot, + subscribe: () => () => {}, + retry() {}, + disconnect() {}, + clearCache: async () => {}, + }; + const props = { + relay, + target: profileTarget(agent.pubkey) ?? "", + close() {}, + context: { channelId: "c", canOpen: () => true, open: () => true }, + }; + let mounted = render(); + return { + session: owner.session, + viewer, + query, + receive: (...events: RelayEvent[]) => + act(async () => { + callbacks.receive(events); + }), + close() { + mounted.unmount(); + }, + open() { + mounted = render(); + }, + setLatest(event: RelayEvent) { + latest = event; + }, + reopen() { + mounted.unmount(); + render(); + }, + rerender() { + mounted.rerender(); + }, + }; +} +const ownerButton = (owner: Key) => ({ + name: `Open owner profile: ${formatPublicKey(owner.pubkey)}`, +}); +const agentReads = (query: ReturnType["query"]) => + query.mock.calls.filter(([filters]) => + filters.some((filter) => filter.kinds?.includes(0)), + ).length; + +it.each(["remove", "duplicate", "replace"])( + "follows the current signed profile after an auth-only %s", + async (mode) => { + const agent = keypair(); + const a = keypair(); + const b = keypair(); + const h = live(agent, timedProfile(agent, [auth(agent, a)], 100)); + await screen.findByRole("button", ownerButton(a)); + const before = h.session.profiles.snapshot().get(agent.pubkey); + const tags = + mode === "remove" + ? [] + : mode === "duplicate" + ? [auth(agent, a), auth(agent, a)] + : [auth(agent, b)]; + const next = timedProfile(agent, tags, 101); + h.setLatest(next); + await h.receive(next); + // A duplicate keeps the projected owner, so only event provenance can drive it. + if (mode === "duplicate") + expect(h.session.profiles.snapshot().get(agent.pubkey)).toBe(before); + h.rerender(); + if (mode === "replace") await screen.findByRole("button", ownerButton(b)); + else { + await verifiedFor(next); + expect(identityRegion()).toBeNull(); + } + expect(screen.queryByRole("button", ownerButton(a))).toBeNull(); + }, +); + +it("does not resurrect older provenance from a lagging finite read", async () => { + const agent = keypair(); + const a = keypair(); + const h = live(agent, timedProfile(agent, [auth(agent, a)], 100)); + await screen.findByRole("button", ownerButton(a)); + // Live knows the newer profile; the finite replica still serves the older head. + const renamed = timedProfile(agent, [], 101, "Renamed"); + await h.receive(renamed); + await screen.findByRole("heading", { name: "Renamed" }); + await verifiedFor(renamed); + expect(identityRegion()).toBeNull(); + expect(screen.queryByRole("button", ownerButton(a))).toBeNull(); +}); + +it("uses the lower event id between equal-time profiles", async () => { + const agent = keypair(); + const a = keypair(); + const b = keypair(); + const first = timedProfile(agent, [auth(agent, a)], 100); + const second = timedProfile(agent, [auth(agent, b)], 100); + const [low, high] = first.id < second.id ? [first, second] : [second, first]; + const [winner, loser] = low === first ? [a, b] : [b, a]; + const h = live(agent, high); + await screen.findByRole("button", ownerButton(loser)); + await h.receive(low); + await screen.findByRole("button", ownerButton(winner)); + await h.receive(high); + h.rerender(); + expect(screen.getByRole("button", ownerButton(winner))).toBeInTheDocument(); + expect(screen.queryByRole("button", ownerButton(loser))).toBeNull(); +}); + +it("shows no owner for a known agent with no public profile", async () => { + const agent = keypair(); + const owner = createRelaySession({ + viewer: keypair().pubkey, + relayAuthor: relayKey.pubkey, + scope: "wss://relay.example.test", + query: async () => [], + media: () => undefined, + readAgentLibrary: async () => ({ + definitions: [], + identities: [{ pubkey: agent.pubkey, name: "Unpublished agent" }], + }), + subscribe: () => ({ update() {}, retry() {}, dispose() {} }), + }); + owners.push(owner); + await owner.session.agentLibrary.refresh(); + let observed: ReturnType | undefined; + const session: RelaySession = Object.create(owner.session, { + observe: { + value: (filters: readonly ReadFilter[]) => { + observed = owner.session.observe(filters); + return observed; + }, + }, + }); + const snapshot = { status: "ready" as const, generation: 1, session }; + const relay: RelayData = { + snapshot: () => snapshot, + subscribe: () => () => {}, + retry() {}, + disconnect() {}, + clearCache: async () => {}, + }; + render( + + {}} + /> + , + ); + await waitFor(() => expect(observed?.snapshot().status).toBe("ready")); + expect(observed?.snapshot().events).toHaveLength(0); + await act(async () => {}); + expect(identityRegion()).toBeNull(); + expect(verify).not.toHaveBeenCalled(); +}); + +it("keeps the newer signed head after closing, cache eviction and a stale reopen read", async () => { + const agent = keypair(); + const a = keypair(); + const sender = keypair(); + const h = live(agent, timedProfile(agent, [auth(agent, a)], 100)); + await screen.findByRole("button", ownerButton(a)); + // The relay keeps serving the older attested head; live saw its removal. + await h.receive(roster(relayKey, "c", [h.viewer, sender.pubkey])); + const removed = timedProfile(agent, [], 101); + await h.receive(removed); + await verifiedFor(removed); + expect(identityRegion()).toBeNull(); + h.close(); + // Churn the session's 8 MiB recent-event cache past the t=101 profile. + for (let batch = 0; batch < 9; batch++) + await h.receive( + ...Array.from({ length: 80 }, (_, index) => + signed(sender, { + kind: 9, + created_at: 200 + batch * 80 + index, + content: "x".repeat(12000), + tags: [["h", "c"]], + }), + ), + ); + const probe = h.session.observe([ + { kinds: [0], authors: [agent.pubkey], limit: 1 }, + ]); + expect(probe.snapshot().events).toHaveLength(0); + probe.dispose(); + const reads = agentReads(h.query); + const since = verify.mock.calls.length; + h.open(); + // The reopened view's read returns the stale t=100 attested profile. + await waitFor(() => expect(agentReads(h.query)).toBeGreaterThan(reads)); + await act(async () => {}); + await verifiedFor(removed, since); + expect(identityRegion()).toBeNull(); + expect(screen.queryByRole("button", ownerButton(a))).toBeNull(); +}); + +it("shows no owner without a relay view even when the directory holds a head", async () => { + const agent = keypair(); + const owner = keypair(); + mount( + agent, + (filter) => + kind0(filter) ? [agentProfile(agent, [auth(agent, owner)])] : [], + { + wrap: (session) => + Object.create(session, { + observe: { + value: () => { + throw new Error("Relay view capacity unavailable"); + }, + }, + }), + }, + ); + await screen.findByRole("heading", { name: "Helper" }); + await act(async () => {}); + // No view can signal an auth-only removal, so the directory head is not trusted here. + expect(identityRegion()).toBeNull(); + expect(verify).not.toHaveBeenCalled(); +}); + +it("follows an auth-only head restored from disk while the pane is mounted", async () => { + const agent = keypair(); + const a = keypair(); + const viewer = keypair(); + const attested = timedProfile(agent, [auth(agent, a)], 100); + // Same owner key, forged signature: the displayed profile is identical, so only + // the directory's winning-event notification can drive the pane. + const removed = timedProfile(agent, [auth(agent, a, keypair())], 101); + let releaseDisk!: (records: SavedHead[]) => void; + const disk = new Promise((resolve) => { + releaseDisk = resolve; + }); + let readStarted!: () => void; + const reading = new Promise((resolve) => { + readStarted = resolve; + }); + const persistence: HeadPersistence = { + read: () => { + readStarted(); + return disk; + }, + write: async () => {}, + retain: async () => {}, + remove: async () => {}, + clear: async () => {}, + close() {}, + }; + const owner = createRelaySession( + { + viewer: viewer.pubkey, + relayAuthor: relayKey.pubkey, + scope: "wss://relay.example.test", + // The relay keeps serving the older, owner-attested profile. + query: async (filters: readonly ReadFilter[]) => + filters.some((filter) => filter.kinds?.includes(39002)) + ? [ + roster(relayKey, "a", [viewer.pubkey]), + metadata(relayKey, "a", "A"), + ] + : filters.some( + (filter) => + kind0(filter) && filter.authors?.includes(agent.pubkey), + ) + ? [attested] + : [], + media: () => undefined, + subscribe: () => ({ update() {}, retry() {}, dispose() {} }), + }, + { prepared: true, persistence }, + ); + owners.push(owner); + const connection = { + status: "ready" as const, + generation: 1, + session: owner.session, + }; + const relay: RelayData = { + snapshot: () => connection, + subscribe: () => () => {}, + retry() {}, + disconnect() {}, + clearCache: async () => {}, + }; + owner.session.channels.ensureList(); + await reading; + render( + {}} + context={{ channelId: "a", canOpen: () => true, open: () => true }} + />, + ); + await screen.findByRole("button", ownerButton(a)); + releaseDisk([ + { + channelId: "a", + savedAt: Date.now(), + events: [ + message(agent, "a", "hi", 90), + bounds(relayKey, "a", "head", { has_more: false, next_cursor: null }), + ], + profiles: [removed], + }, + ]); + await verifiedFor(removed); + expect(identityRegion()).toBeNull(); + expect(owner.session.profiles.event?.(agent.pubkey)?.id).toBe(removed.id); + expect(screen.queryByRole("button", ownerButton(a))).toBeNull(); +}); + +it.each(["remove", "replace", "equal-time removal"])( + "shows no owner at the real view cap through an auth-only %s, then a reopen recovers", + async (mode) => { + const agent = keypair(); + const a = keypair(); + const b = keypair(); + const first = timedProfile(agent, [auth(agent, a)], 100); + let next = timedProfile( + agent, + mode === "replace" ? [auth(agent, b)] : [], + 101, + ); + if (mode === "equal-time removal") + for ( + let nonce = 0; + next.created_at !== 100 || next.id > first.id; + nonce++ + ) + next = timedProfile(agent, [["nonce", String(nonce)]], 100); + const h = live(agent, first); + await screen.findByRole("button", ownerButton(a)); + h.close(); + const fillers: ReturnType[] = []; + try { + for (;;) fillers.push(h.session.observe([{ kinds: [1], limit: 1 }])); + } catch {} + const atCap = verify.mock.calls.length; + try { + h.open(); + // Without a view nothing is verified, so no owner can appear later either. + const empty = async () => { + await screen.findByRole("heading", { name: "Helper" }); + await act(async () => {}); + expect(identityRegion()).toBeNull(); + expect(verify.mock.calls.length).toBe(atCap); + }; + await empty(); + // The relay keeps serving the older attested head; live delivers the change. + await h.receive(next); + expect(h.session.profiles.event?.(agent.pubkey)?.id).toBe(next.id); + await empty(); + h.close(); + h.open(); + await empty(); + fillers.pop()?.dispose(); + h.close(); + const since = verify.mock.calls.length; + h.open(); + if (mode === "replace") await screen.findByRole("button", ownerButton(b)); + else { + await verifiedFor(next, since); + expect(identityRegion()).toBeNull(); + } + expect(screen.queryByRole("button", ownerButton(a))).toBeNull(); + // The recovered view keeps following later auth-only changes. + const later = timedProfile(agent, [], 102); + await h.receive(later); + await verifiedFor(later, since); + expect(identityRegion()).toBeNull(); + } finally { + for (const filler of fillers) filler.dispose(); + } + }, +); diff --git a/src/bundled/profiles/ProfileAgentIdentity.tsx b/src/bundled/profiles/ProfileAgentIdentity.tsx new file mode 100644 index 000000000..d8b211e2c --- /dev/null +++ b/src/bundled/profiles/ProfileAgentIdentity.tsx @@ -0,0 +1,154 @@ +import { useEffect, useMemo, useState, useSyncExternalStore } from "react"; +import { attestedOwner } from "../../features/agents/owner-attestation"; +import { useIdentityNames } from "../../features/identity-names/react"; +import type { PanelProps } from "../../features/panels/service"; +import { profileTarget } from "../../features/profiles/target"; +import { type EventData, newer } from "../../features/relay/events"; +import { selectProfiles } from "../../features/relay/profile-selection"; +import type { + EventViewSnapshot, + RelaySession, +} from "../../features/relay/session"; +import { Button } from "../../shared/design-system/ui/Button"; +import { formatPublicKey } from "../../shared/identity/public-key"; +import styles from "./Profiles.module.css"; + +type ProfileView = ReturnType; +const noSubscribe = () => () => {}; +const pendingView = (): EventViewSnapshot => pendingSnapshot; +const pendingSnapshot: EventViewSnapshot = Object.freeze({ + status: "loading", + events: [], +}); + +/** "Managed by" from verifiable relay evidence only: a verified NIP-OA owner on + * the agent's winning signed kind 0. Mounted for an agent hint, which decides + * visibility but never who owns the key. Renders nothing without a verified owner. */ +export function ProfileAgentIdentity({ + session, + pubkey, + viewer, + context, +}: { + session: RelaySession; + pubkey: string; + viewer: string | undefined; + context: PanelProps["context"]; +}) { + // A session-owned view: live events, reconnect refresh and purge, no polling. + // Capacity or a closed session leaves no view; reopening the profile retries. + const [view, setView] = useState(); + useEffect(() => { + let owned: ProfileView; + try { + owned = session.observe([{ kinds: [0], authors: [pubkey], limit: 1 }]); + } catch { + setView(null); + return; + } + setView(owned); + return owned.dispose; + }, [session, pubkey]); + const events = useSyncExternalStore( + view?.subscribe ?? noSubscribe, + view?.snapshot ?? pendingView, + view?.snapshot ?? pendingView, + ); + // Provenance belongs to the winning signed kind 0 alone, never a display projection. + // The directory's retained head outlives this view, so a reopened pane cannot + // accept an older response than the profile the rest of the session shows. + // Subscribed, so any head change (live, read or disk restore) re-renders. + // Without a live view nothing can signal an auth-only change, so show nothing. + const directoryHead = useSyncExternalStore( + session.profiles.subscribe, + () => session.profiles.event?.(pubkey), + () => session.profiles.event?.(pubkey), + ); + const head = view ? directoryHead : undefined; + const latest = events.events + .filter( + (event) => + event.kind === 0 && + event.pubkey === pubkey && + event.delivery !== "failed", + ) + .reduce(newer, head); + const [verified, setVerified] = useState<{ id: string; owner?: string }>(); + useEffect(() => { + if (!latest) return; + let active = true; + void attestedOwner(latest).then((owner) => { + if (active) setVerified({ id: latest.id, ...(owner ? { owner } : {}) }); + }); + return () => { + active = false; + }; + }, [latest]); + useEffect(() => { + if (events.status === "idle") void view?.refresh(); + }, [view, events.status]); + const owner = + verified && latest && verified.id === latest.id + ? verified.owner + : undefined; + if (!owner) return null; + return ( +
+

Managed by

+ +
+ ); +} + +function OwnerLink({ + session, + owner, + self, + context, +}: { + session: RelaySession; + owner: string; + self: boolean; + context: PanelProps["context"]; +}) { + const selection = useMemo( + () => selectProfiles(session.profiles, [owner]), + [session.profiles, owner], + ); + const profiles = useSyncExternalStore( + selection.subscribe, + selection.snapshot, + selection.snapshot, + ); + useEffect(() => { + void session.profiles.ensure([owner], "background").catch(() => {}); + }, [session, owner]); + const identityName = useIdentityNames(session.names); + const shown = identityName( + owner, + profiles.get(owner)?.name ?? formatPublicKey(owner) ?? owner, + ); + const name = self ? `${shown} (you)` : shown; + const target = profileTarget(owner); + return ( +
+ {target && context?.canOpen(target) ? ( + + ) : ( + name + )} +
+ ); +} diff --git a/src/bundled/profiles/ProfilePanel.tsx b/src/bundled/profiles/ProfilePanel.tsx index 0b75db760..d8411ce71 100644 --- a/src/bundled/profiles/ProfilePanel.tsx +++ b/src/bundled/profiles/ProfilePanel.tsx @@ -27,6 +27,7 @@ import { selectProfiles } from "../../features/relay/profile-selection"; import { useRelayConnection } from "../../features/relay/react"; import type { RelayData } from "../../features/relay/service"; import type { RelaySession } from "../../features/relay/session"; +import { ProfileAgentIdentity } from "./ProfileAgentIdentity"; import styles from "./Profiles.module.css"; export function ProfilePanel({ @@ -178,6 +179,14 @@ function ProfileDetails({

{profile.about}

)} {children} + {agentPubkeys.has(pubkey) && ( + + )} ({ + pubkey: agent, + kind: 1, + created_at: 1713956400, + tags, + ...overrides, +}); + +it("returns the owner for the NIP-OA vector", async () => { + expect(await attestedOwner(event([["auth", owner, conditions, sig]]))).toBe( + owner, + ); +}); + +it.each([ + ["no tag", []], + [ + "two tags", + [ + ["auth", owner, conditions, sig], + ["auth", owner, conditions, sig], + ], + ], + ["five elements", [["auth", owner, conditions, sig, "x"]]], + ["trailing delimiter", [["auth", owner, `${conditions}&`, sig]]], + ["leading zero", [["auth", owner, "kind=01", sig]]], + [ + "reordered conditions", + [["auth", owner, "created_at<1713957000&kind=1", sig]], + ], + ["tampered signature", [["auth", owner, conditions, `${sig.slice(0, -1)}8`]]], + ["uppercase owner", [["auth", owner.toUpperCase(), conditions, sig]]], +])("rejects %s", async (_name, tags) => { + expect(await attestedOwner(event(tags))).toBeUndefined(); +}); + +it("evaluates conditions against the event", async () => { + const tag = [["auth", owner, conditions, sig]]; + expect(await attestedOwner(event(tag, { kind: 0 }))).toBeUndefined(); + expect( + await attestedOwner(event(tag, { created_at: 1713957000 })), + ).toBeUndefined(); +}); + +it("rejects self-attestation and another agent key", async () => { + expect( + await attestedOwner( + event([["auth", owner, conditions, sig]], { pubkey: owner }), + ), + ).toBeUndefined(); + expect( + await attestedOwner( + event([["auth", owner, conditions, sig]], { pubkey: "b".repeat(64) }), + ), + ).toBeUndefined(); +}); diff --git a/src/features/agents/owner-attestation.ts b/src/features/agents/owner-attestation.ts new file mode 100644 index 000000000..b8c3e57f4 --- /dev/null +++ b/src/features/agents/owner-attestation.ts @@ -0,0 +1,62 @@ +import { schnorr } from "@noble/curves/secp256k1.js"; +import { hexToBytes } from "nostr-tools/utils"; +import type { EventData } from "../relay/events"; + +const KEY = /^[0-9a-f]{64}$/; +const SIG = /^[0-9a-f]{128}$/; +const CLAUSE = /^(kind=|created_at<|created_at>)(0|[1-9][0-9]*)$/; + +/** NIP-OA owner of a signature-verified event, or undefined. Provenance only: + * the event stays authored by `event.pubkey`; the owner is never an author. */ +export async function attestedOwner( + event: Pick, +): Promise { + const tags = event.tags.filter((tag) => tag[0] === "auth"); + const [, owner, conditions, sig] = tags[0] ?? []; + if ( + tags.length !== 1 || + tags[0]?.length !== 4 || + !owner || + conditions === undefined || + !sig || + !KEY.test(owner) || + !SIG.test(sig) || + owner === event.pubkey || + !satisfied(conditions, event) + ) + return undefined; + try { + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode( + `nostr:agent-auth:${event.pubkey}:${conditions}`, + ), + ); + return schnorr.verify( + hexToBytes(sig), + new Uint8Array(digest), + hexToBytes(owner), + ) + ? owner + : undefined; + } catch { + return undefined; + } +} + +function satisfied( + conditions: string, + event: Pick, +) { + if (conditions === "") return true; + return conditions.split("&").every((clause) => { + const [, name, digits] = CLAUSE.exec(clause) ?? []; + if (!name || !digits) return false; + const value = Number(digits); + if (name === "kind=") return value <= 65535 && event.kind === value; + if (value > 4294967295) return false; + return name === "created_at<" + ? event.created_at < value + : event.created_at > value; + }); +} diff --git a/src/features/relay/profile-details.test.ts b/src/features/relay/profile-details.test.ts index a60b24c6f..9ca1c96a0 100644 --- a/src/features/relay/profile-details.test.ts +++ b/src/features/relay/profile-details.test.ts @@ -101,9 +101,10 @@ it.each([ expect(selectedChanged).toHaveBeenCalledTimes(1); // A newer event with identical display values must still preserve identity. + // The directory still notifies: its winning signed event changed. directory.accept([profile(user, { name: "Mic", [field]: true }, 3)]); expect(selection.snapshot()).toBe(added); - expect(directoryChanged).toHaveBeenCalledTimes(1); + expect(directoryChanged).toHaveBeenCalledTimes(2); expect(selectedChanged).toHaveBeenCalledTimes(1); directory.accept([profile(user, { name: "Mic", ...removal }, 4)]); @@ -113,7 +114,7 @@ it.each([ directory.queries.snapshot().get(user.pubkey), ); expect(removed.get(user.pubkey)).toEqual({ name: "Mic" }); - expect(directoryChanged).toHaveBeenCalledTimes(2); + expect(directoryChanged).toHaveBeenCalledTimes(3); expect(selectedChanged).toHaveBeenCalledTimes(2); } finally { unsubscribeSelection(); diff --git a/src/features/relay/profile-directory.ts b/src/features/relay/profile-directory.ts index 9590d273b..a795e3616 100644 --- a/src/features/relay/profile-directory.ts +++ b/src/features/relay/profile-directory.ts @@ -12,6 +12,8 @@ export interface ProfileQueries { subscribe(listener: () => void): () => void; /** Fetch missing profiles; optional enrichment can yield to conversation reads. */ ensure(ids: readonly string[], priority?: Priority): Promise; + /** The winning signed kind 0 retained for one identity, for provenance checks. */ + event?(pubkey: string): RelayEvent | undefined; } /** One bounded source of signed profile events. Display values are derived from it. */ @@ -36,9 +38,10 @@ export function createProfileDirectory( events.set(event.pubkey, event); changed = true; } - if (changed) publish(); + if (changed) publish(true); } - function publish() { + /** Notifies on display changes, and on winning-event changes for `event()` readers. */ + function publish(headChanged = false) { const next = foldProfiles([ ...events.keys().flatMap((id) => { const event = events.peek(id); @@ -61,11 +64,11 @@ export function createProfileDirectory( next.set(id, old); } if ( - next.size === snapshot.size && - [...next].every(([id, value]) => snapshot.get(id) === value) + next.size !== snapshot.size || + [...next].some(([id, value]) => snapshot.get(id) !== value) ) - return; - snapshot = next; + snapshot = next; + else if (!headChanged) return; for (const listener of listeners) notify(listener); } async function ensure( @@ -116,6 +119,7 @@ export function createProfileDirectory( }; }, ensure, + event: (pubkey: string) => events.peek(pubkey), }); let localProfiles = ""; const unsubscribeLocal = local?.subscribe(() => {