From fef1c3516994fe953958951026b202378a9af4cc Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Mon, 20 Jul 2026 21:53:09 +0200 Subject: [PATCH] feat(web): authenticate gated repository browsing Use NIP-07 identities for relay and Git authentication, and let members claim community invites in the browser without exposing private keys to the page. --- web/src/features/invite/invite-api.ts | 51 +++++++++++ web/src/features/invite/ui/InvitePage.tsx | 82 +++++++++++++---- web/src/features/repos/git-client.ts | 12 ++- web/src/shared/lib/nip98.ts | 41 ++++++--- web/src/shared/lib/nostr-client.ts | 79 +++++++++------- web/src/shared/lib/nostr-signer.ts | 106 ++++++++++++++++++++++ web/tests/e2e/smoke.spec.ts | 88 +++++++++++++++++- 7 files changed, 393 insertions(+), 66 deletions(-) create mode 100644 web/src/features/invite/invite-api.ts create mode 100644 web/src/shared/lib/nostr-signer.ts diff --git a/web/src/features/invite/invite-api.ts b/web/src/features/invite/invite-api.ts new file mode 100644 index 0000000000..ec898123ed --- /dev/null +++ b/web/src/features/invite/invite-api.ts @@ -0,0 +1,51 @@ +import { makeNip98AuthHeader } from "@/shared/lib/nip98"; +import { relayHttpBaseUrl } from "@/shared/lib/relay-url"; + +const INVITE_REQUEST_TIMEOUT_MS = 15_000; + +export type BrowserInviteClaim = { + status: "joined" | "already_member"; + communityId: string; + host: string; + role: string; +}; + +export async function claimInviteInBrowser( + code: string, + policyReceipt?: string, +): Promise { + const url = `${relayHttpBaseUrl().replace(/\/+$/, "")}/api/invites/claim`; + const body = JSON.stringify({ + code, + policy_receipt: policyReceipt, + }); + const authorization = await makeNip98AuthHeader(url, "POST", { + body, + requireNip07: true, + }); + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: authorization, + "Content-Type": "application/json", + }, + body, + signal: AbortSignal.timeout(INVITE_REQUEST_TIMEOUT_MS), + }); + const json = (await response.json().catch(() => ({}))) as Record< + string, + unknown + >; + if (!response.ok) { + const message = + typeof json.error === "string" ? json.error : `HTTP ${response.status}`; + throw new Error(message); + } + + return { + status: json.status as BrowserInviteClaim["status"], + communityId: String(json.community_id), + host: String(json.host), + role: String(json.role), + }; +} diff --git a/web/src/features/invite/ui/InvitePage.tsx b/web/src/features/invite/ui/InvitePage.tsx index 7415ad1dfe..6de8d03875 100644 --- a/web/src/features/invite/ui/InvitePage.tsx +++ b/web/src/features/invite/ui/InvitePage.tsx @@ -1,10 +1,12 @@ import buzzAppIcon from "@/assets/app-icon@3x.png"; +import { claimInviteInBrowser } from "@/features/invite/invite-api"; import { BUZZ_RELEASES_URL, type BuzzDownloadPlatform, detectBuzzDownloadPlatform, resolveBuzzDownloadUrlForPlatform, } from "@/shared/lib/buzz-download"; +import { hasNip07Provider } from "@/shared/lib/nostr-signer"; import { relayWsUrl } from "@/shared/lib/relay-url"; import { Button } from "@/shared/ui/button"; import * as React from "react"; @@ -33,6 +35,10 @@ export function InvitePage({ code }: { code: string }) { const [ageConfirmed, setAgeConfirmed] = React.useState(false); const [agreementConfirmed, setAgreementConfirmed] = React.useState(false); const [opening, setOpening] = React.useState(false); + const [joiningBrowser, setJoiningBrowser] = React.useState(false); + const [browserJoinError, setBrowserJoinError] = React.useState( + null, + ); const [downloadUrl, setDownloadUrl] = React.useState(BUZZ_RELEASES_URL); const [needsMacChoice, setNeedsMacChoice] = React.useState(false); const [showMacChoice, setShowMacChoice] = React.useState(false); @@ -69,23 +75,25 @@ export function InvitePage({ code }: { code: string }) { .catch(() => setPolicy(undefined)); }, []); + const acceptPolicy = async (): Promise => { + if (!policy) return undefined; + const response = await fetch("/api/invites/accept-policy", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + code, + policy_version: policy.version, + age_confirmed: ageConfirmed, + }), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return ((await response.json()) as { receipt: string }).receipt; + }; + const openInvite = async () => { setOpening(true); try { - let receipt: string | undefined; - if (policy) { - const response = await fetch("/api/invites/accept-policy", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - code, - policy_version: policy.version, - age_confirmed: ageConfirmed, - }), - }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - receipt = ((await response.json()) as { receipt: string }).receipt; - } + const receipt = await acceptPolicy(); const query = new URLSearchParams({ relay, code }); if (receipt) query.set("policy_receipt", receipt); window.location.href = `buzz://join?${query.toString()}`; @@ -94,9 +102,27 @@ export function InvitePage({ code }: { code: string }) { } }; + const joinInBrowser = async () => { + setBrowserJoinError(null); + setJoiningBrowser(true); + try { + const receipt = await acceptPolicy(); + await claimInviteInBrowser(code, receipt); + window.location.assign("/"); + } catch (error) { + setBrowserJoinError( + error instanceof Error ? error.message : "Could not claim this invite.", + ); + } finally { + setJoiningBrowser(false); + } + }; + + const browserSigningAvailable = hasNip07Provider(); const disabled = policy === undefined || opening || + joiningBrowser || Boolean(policy?.age_attestation_required && !ageConfirmed) || Boolean( policy && @@ -185,11 +211,24 @@ export function InvitePage({ code }: { code: string }) { -
+
+ {browserSigningAvailable ? ( + + ) : null} {policy === null ? ( )} + {browserJoinError ? ( +

+ {browserJoinError} +

+ ) : null}

diff --git a/web/src/features/repos/git-client.ts b/web/src/features/repos/git-client.ts index 21255d8d19..1a685ba573 100644 --- a/web/src/features/repos/git-client.ts +++ b/web/src/features/repos/git-client.ts @@ -49,9 +49,15 @@ function repoAuthUrl(owner: string, repoName: string): string { return `${relayHttpBaseUrl()}/git/${owner}/${repoName}.git`; } -function authHeaders(owner: string, repoName: string): Record { +async function authHeaders( + owner: string, + repoName: string, +): Promise> { return { - Authorization: makeNip98AuthHeader(repoAuthUrl(owner, repoName), "GET"), + Authorization: await makeNip98AuthHeader( + repoAuthUrl(owner, repoName), + "GET", + ), }; } @@ -67,7 +73,7 @@ export async function ensureClone( const fs = getFs(owner, repoName); const dir = getDir(owner, repoName); const url = repoGitUrl(owner, repoName); - const headers = authHeaders(owner, repoName); + const headers = await authHeaders(owner, repoName); let exists = false; try { diff --git a/web/src/shared/lib/nip98.ts b/web/src/shared/lib/nip98.ts index 0e4da5df53..da0e7d628b 100644 --- a/web/src/shared/lib/nip98.ts +++ b/web/src/shared/lib/nip98.ts @@ -3,28 +3,43 @@ * HTTP requests to the relay (used by isomorphic-git for smart HTTP transport). */ -import { finalizeEvent } from "nostr-tools/pure"; -import { getEphemeralKey } from "./nostr-client"; +import { signNostrEvent } from "./nostr-signer"; + +async function sha256Hex(value: string): Promise { + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(value), + ); + return Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} /** * Build a NIP-98 Authorization header value. * - * Creates a kind:27235 event with `u` and `method` tags, signs it with the - * session's ephemeral key, base64-encodes the JSON, and returns - * `"Nostr "`. + * Signed POST bodies include the payload digest required by invite endpoints. */ -export function makeNip98AuthHeader(url: string, method: string): string { - const event = finalizeEvent( +export async function makeNip98AuthHeader( + url: string, + method: string, + options?: { body?: string; requireNip07?: boolean }, +): Promise { + const tags = [ + ["u", url], + ["method", method], + ]; + if (options?.body !== undefined) { + tags.push(["payload", await sha256Hex(options.body)]); + tags.push(["nonce", crypto.randomUUID()]); + } + const event = await signNostrEvent( { kind: 27235, - created_at: Math.floor(Date.now() / 1000), - tags: [ - ["u", url], - ["method", method], - ], + tags, content: "", }, - getEphemeralKey(), + { requireNip07: options?.requireNip07 }, ); const json = JSON.stringify(event); diff --git a/web/src/shared/lib/nostr-client.ts b/web/src/shared/lib/nostr-client.ts index ab77e11cea..d8a986ef27 100644 --- a/web/src/shared/lib/nostr-client.ts +++ b/web/src/shared/lib/nostr-client.ts @@ -1,12 +1,15 @@ /** * Minimal Nostr client with NIP-01 queries and NIP-42 AUTH. * - * Generates an ephemeral keypair per session to authenticate with the relay. - * This is sufficient for read-only public queries (e.g. repo listings). + * Uses NIP-07 when a browser extension is available, with an ephemeral + * page-lifetime identity as the fallback for read-only queries on open relays. */ -import { generateSecretKey, finalizeEvent } from "nostr-tools/pure"; import { makeAuthEvent } from "nostr-tools/nip42"; +import { + type SignedNostrEvent, + signNostrEvent, +} from "@/shared/lib/nostr-signer"; export interface NostrFilter { ids?: string[]; @@ -18,27 +21,10 @@ export interface NostrFilter { [tag: `#${string}`]: string[] | undefined; } -export interface NostrEvent { - id: string; - pubkey: string; - kind: number; - tags: string[][]; - content: string; - created_at: number; - sig: string; -} +export type NostrEvent = SignedNostrEvent; const QUERY_TIMEOUT_MS = 10_000; -/** Lazily-generated ephemeral keypair for NIP-42 AUTH. */ -let _secretKey: Uint8Array | null = null; -export function getEphemeralKey(): Uint8Array { - if (!_secretKey) { - _secretKey = generateSecretKey(); - } - return _secretKey; -} - /** * Open a WebSocket to `wsUrl`, authenticate via NIP-42 if challenged, * send a REQ with the given filter, collect EVENTs until EOSE, then @@ -53,6 +39,8 @@ export function queryEvents( const subId = `q-${Date.now().toString(36)}`; let settled = false; let reqSent = false; + let authEventId: string | null = null; + let unauthenticatedReqTimer: ReturnType | null = null; const ws = new WebSocket(wsUrl); @@ -66,6 +54,9 @@ export function queryEvents( const cleanup = () => { clearTimeout(timeout); + if (unauthenticatedReqTimer) { + clearTimeout(unauthenticatedReqTimer); + } try { ws.close(); } catch { @@ -83,10 +74,10 @@ export function queryEvents( ws.addEventListener("open", () => { // Wait briefly for an AUTH challenge before sending REQ. // Buzz relays always send AUTH, but other relays may not. - setTimeout(() => sendReq(), 100); + unauthenticatedReqTimer = setTimeout(() => sendReq(), 100); }); - ws.addEventListener("message", (msg) => { + ws.addEventListener("message", async (msg) => { let data: unknown; try { data = JSON.parse(String(msg.data)); @@ -99,19 +90,45 @@ export function queryEvents( if (type === "AUTH" && typeof data[1] === "string") { // NIP-42: relay sent an AUTH challenge — sign and respond. + if (unauthenticatedReqTimer) { + clearTimeout(unauthenticatedReqTimer); + unauthenticatedReqTimer = null; + } const challenge = data[1]; - const sk = getEphemeralKey(); const template = makeAuthEvent(wsUrl, challenge); - const signed = finalizeEvent(template, sk); - ws.send(JSON.stringify(["AUTH", signed])); - // After AUTH, send our REQ. - sendReq(); + try { + const signed = await signNostrEvent(template); + if (settled) return; + authEventId = signed.id; + ws.send(JSON.stringify(["AUTH", signed])); + } catch (error) { + if (!settled) { + settled = true; + cleanup(); + reject( + error instanceof Error + ? error + : new Error("Failed to sign relay authentication."), + ); + } + } return; } - if (type === "OK") { - // AUTH response accepted (or rejected). If we haven't sent REQ yet, do so now. - sendReq(); + if (type === "OK" && data[1] === authEventId) { + if (data[2] === true) { + sendReq(); + } else if (!settled) { + settled = true; + cleanup(); + reject( + new Error( + typeof data[3] === "string" + ? data[3] + : "Relay authentication failed.", + ), + ); + } return; } diff --git a/web/src/shared/lib/nostr-signer.ts b/web/src/shared/lib/nostr-signer.ts new file mode 100644 index 0000000000..a32d3a680b --- /dev/null +++ b/web/src/shared/lib/nostr-signer.ts @@ -0,0 +1,106 @@ +import { + finalizeEvent, + generateSecretKey, + getPublicKey, +} from "nostr-tools/pure"; + +export type UnsignedNostrEvent = { + kind: number; + created_at: number; + tags: string[][]; + content: string; +}; + +export type SignedNostrEvent = UnsignedNostrEvent & { + id: string; + pubkey: string; + sig: string; +}; + +type Nip07Provider = { + getPublicKey(): Promise; + signEvent(event: UnsignedNostrEvent): Promise; +}; + +declare global { + interface Window { + nostr?: Nip07Provider; + } +} + +export class Nip07UnavailableError extends Error { + constructor() { + super("A NIP-07 browser extension is required to join in the browser."); + this.name = "Nip07UnavailableError"; + } +} + +let ephemeralSecretKey: Uint8Array | null = null; + +function getEphemeralSecretKey(): Uint8Array { + if (!ephemeralSecretKey) { + ephemeralSecretKey = generateSecretKey(); + } + return ephemeralSecretKey; +} + +export function hasNip07Provider(): boolean { + return typeof window !== "undefined" && window.nostr != null; +} + +function sameUnsignedEvent( + expected: UnsignedNostrEvent, + actual: SignedNostrEvent, +): boolean { + return ( + actual.kind === expected.kind && + actual.created_at === expected.created_at && + actual.content === expected.content && + JSON.stringify(actual.tags) === JSON.stringify(expected.tags) + ); +} + +/** + * Sign with NIP-07 when available, otherwise use a page-lifetime key. + * + * The ephemeral fallback preserves anonymous browsing on open relays. Flows + * that create durable membership must set `requireNip07` so a reload cannot + * orphan a relay-membership row. + */ +export async function signNostrEvent( + template: Omit & { + created_at?: number; + }, + options?: { requireNip07?: boolean }, +): Promise { + const unsigned: UnsignedNostrEvent = { + ...template, + created_at: template.created_at ?? Math.floor(Date.now() / 1000), + }; + const provider = typeof window === "undefined" ? undefined : window.nostr; + + if (provider) { + const expectedPubkey = await provider.getPublicKey(); + const signed = await provider.signEvent(unsigned); + if ( + signed.pubkey !== expectedPubkey || + !sameUnsignedEvent(unsigned, signed) || + typeof signed.id !== "string" || + typeof signed.sig !== "string" + ) { + throw new Error("The NIP-07 extension returned an invalid signed event."); + } + return signed; + } + + if (options?.requireNip07) { + throw new Nip07UnavailableError(); + } + + const secretKey = getEphemeralSecretKey(); + const signed = finalizeEvent(unsigned, secretKey); + if (signed.pubkey !== getPublicKey(secretKey)) { + throw new Error("Failed to create the ephemeral browser identity."); + } + return signed; +} diff --git a/web/tests/e2e/smoke.spec.ts b/web/tests/e2e/smoke.spec.ts index 9f901448b8..22e2628165 100644 --- a/web/tests/e2e/smoke.spec.ts +++ b/web/tests/e2e/smoke.spec.ts @@ -1,8 +1,11 @@ +import { createHash } from "node:crypto"; import { expect, test } from "@playwright/test"; -test("home page loads with Buzz heading", async ({ page }) => { +test("home page loads with Buzz branding", async ({ page }) => { await page.goto("/"); - await expect(page.locator("header")).toContainText("Buzz"); + await expect( + page.getByRole("main").getByRole("img", { name: "Buzz" }), + ).toBeVisible(); }); test("home page shows repositories section", async ({ page }) => { @@ -117,6 +120,87 @@ test("invite requires age and legal consent before opening Buzz", async ({ expect(consentBox?.width).toBe(acceptButtonBox?.width); }); +test("invite can enroll a NIP-07 identity for browser access", async ({ + page, +}) => { + const pubkey = "ab".repeat(32); + await page.addInitScript((extensionPubkey) => { + ( + window as Window & { + nostr?: { + getPublicKey(): Promise; + signEvent( + event: Record, + ): Promise>; + }; + } + ).nostr = { + async getPublicKey() { + return extensionPubkey; + }, + async signEvent(event) { + return { + ...event, + id: "cd".repeat(32), + pubkey: extensionPubkey, + sig: "ef".repeat(64), + }; + }, + }; + }, pubkey); + await page.route("**/api/join-policy", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ policy: null }), + }); + }); + + let claimObserved = false; + await page.route("**/api/invites/claim", async (route) => { + claimObserved = true; + const request = route.request(); + const body = request.postData() ?? ""; + expect(JSON.parse(body)).toEqual({ + code: "browser-code", + }); + + const authorization = request.headers().authorization; + expect(authorization).toMatch(/^Nostr /); + const event = JSON.parse( + Buffer.from(authorization.slice("Nostr ".length), "base64").toString( + "utf8", + ), + ) as { + pubkey: string; + tags: string[][]; + }; + expect(event.pubkey).toBe(pubkey); + expect(event.tags).toContainEqual(["u", request.url()]); + expect(event.tags).toContainEqual(["method", "POST"]); + expect(event.tags).toContainEqual([ + "payload", + createHash("sha256").update(body).digest("hex"), + ]); + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + status: "joined", + community_id: "community-id", + host: "127.0.0.1", + role: "member", + }), + }); + }); + + await page.goto("/invite/browser-code"); + await page.getByRole("button", { name: "Join in browser" }).click(); + await expect(page).toHaveURL("/"); + expect(claimObserved).toBe(true); +}); + test("invite asks Safari users to choose their Mac download", async ({ browser, }) => {