feat(web): authenticate gated community browsing (#2190)

This commit is contained in:
thomaspblock
2026-07-21 00:29:25 +02:00
committed by GitHub
parent 0f86a608b9
commit 1210e2a5f7
7 changed files with 393 additions and 66 deletions

View File

@@ -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<string> {
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 <base64>"`.
* 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<string> {
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);

View File

@@ -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<typeof setTimeout> | 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;
}

View File

@@ -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<string>;
signEvent(event: UnsignedNostrEvent): Promise<SignedNostrEvent>;
};
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<UnsignedNostrEvent, "created_at"> & {
created_at?: number;
},
options?: { requireNip07?: boolean },
): Promise<SignedNostrEvent> {
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;
}