fix(nostr): validate relay/auth config and add coverage

This commit is contained in:
Darshil
2026-02-05 12:57:13 -08:00
parent 2951c0e1bc
commit 32b175a424
13 changed files with 838 additions and 306 deletions

View File

@@ -1,5 +1,22 @@
# Changelog
## 2026.2.5
### Changes
- Upgrade default outbound DM protocol to NIP-17, with `dmProtocol: "nip04"` fallback.
- Keep inbound compatibility by reading both NIP-04 (`kind:4`) and NIP-17 (`kind:1059`) DMs.
- Add NIP-42 AUTH signing support for auth-required relays.
- Add NIP-65 relay discovery with safer relay URL filtering and fallback behavior.
- Fix `npub` normalization to decode directly to hex pubkeys.
- Add regression/unit tests for NIP-42 auth signing, NIP-65 relay handling, and `npub` normalization.
## 2026.2.4
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.2
### Changes

View File

@@ -10,11 +10,13 @@ This extension adds Nostr as a messaging channel to OpenClaw. It enables your bo
- Send encrypted responses back
- Work with NIP-17 compatible clients (0xchat, Amethyst, Damus, Primal, etc.)
- Automatically discover recipient's preferred relays (NIP-65)
- Authenticate with relays that require NIP-42 AUTH challenges
## What's New in v2
- **NIP-17 by default** — Gift-wrapped messages hide sender/recipient from relays
- **NIP-65 relay discovery** — Finds recipient's preferred relays before sending
- **NIP-42 auth support** — Handles auth-required relays automatically
- **Backwards compatible** — Set `dmProtocol: "nip04"` if you need legacy support
## Installation
@@ -68,6 +70,7 @@ openclaw plugins install @openclaw/nostr
### NIP-17 (Default, Recommended)
Gift-wrapped messages provide metadata privacy:
- Sender and recipient pubkeys hidden from relays
- Forward secrecy with ephemeral keys
- Supported by modern clients (0xchat, Amethyst, Damus, Primal)
@@ -75,13 +78,14 @@ Gift-wrapped messages provide metadata privacy:
### NIP-04 (Legacy)
Use only for backwards compatibility:
- Sender/recipient visible to relays
- Older clients may only support this
```yaml
channels:
nostr:
dmProtocol: "nip04" # Use legacy protocol
dmProtocol: "nip04" # Use legacy protocol
```
## NIP-65 Relay Discovery
@@ -122,16 +126,17 @@ If you're upgrading from the NIP-04-only version:
1. **Default behavior changed** — DMs now use NIP-17
2. **Most users**: No action needed (NIP-17 is better)
3. **Legacy clients**: Add `dmProtocol: "nip04"` to keep old behavior
4. **Inbound compatibility**: The plugin still reads both NIP-04 and NIP-17 inbound DMs
## Protocol Support
| NIP | Status | Notes |
| ------ | --------- | ------------------------------- |
| NIP-01 | Supported | Basic event structure |
| NIP-04 | Supported | Legacy encrypted DMs (opt-in) |
| NIP-17 | Supported | Gift-wrapped DMs (default) |
| NIP-42 | Planned | Relay authentication |
| NIP-65 | Supported | Relay list discovery |
| NIP | Status | Notes |
| ------ | --------- | ----------------------------- |
| NIP-01 | Supported | Basic event structure |
| NIP-04 | Supported | Legacy encrypted DMs (opt-in) |
| NIP-17 | Supported | Gift-wrapped DMs (default) |
| NIP-42 | Supported | Relay AUTH challenge handling |
| NIP-65 | Supported | Relay list discovery |
## Security Notes
@@ -155,12 +160,13 @@ If you're upgrading from the NIP-04-only version:
1. Check relay URLs are correct (must use `wss://`)
2. Verify relays are online and accepting connections
3. NIP-65 will try to find recipient's preferred relays
3. NIP-65 will try to find recipient's preferred relays (public `wss://` only)
4. Check for rate limiting (reduce message frequency)
### Legacy client compatibility
If the recipient uses an old client:
```yaml
channels:
nostr:

View File

@@ -76,7 +76,7 @@ export const NostrConfigSchema = z.object({
* DM protocol version:
* - "nip17" (default): Gift-wrapped messages with metadata privacy
* - "nip04": Legacy encrypted DMs (metadata visible to relays)
*
*
* NIP-17 is recommended. Use NIP-04 only for backwards compatibility
* with very old clients that don't support NIP-17.
*/

View File

@@ -2,8 +2,8 @@
* Tests for NIP-17 gift wrap implementation
*/
import { describe, it, expect } from "vitest";
import { getPublicKey } from "nostr-tools";
import { describe, it, expect } from "vitest";
import {
hexToBytes,
bytesToHex,
@@ -100,17 +100,23 @@ describe("normalizeNostrTarget", () => {
it("returns null for wrong length hex", () => {
expect(normalizeNostrTarget("c220169537593d7126e9842f31a8d4d5")).toBeNull(); // 32 chars
expect(normalizeNostrTarget("c220169537593d7126e9842f31a8d4d5fa66e271ce396f12ddc2d455db855bf2aa")).toBeNull(); // 66 chars
expect(
normalizeNostrTarget("c220169537593d7126e9842f31a8d4d5fa66e271ce396f12ddc2d455db855bf2aa"),
).toBeNull(); // 66 chars
});
});
describe("looksLikeNostrId", () => {
it("recognizes npub format", () => {
expect(looksLikeNostrId("npub1cgspd9fhty7hzfhfsshnr2x56haxdcn3ecuk7ykact29tku9t0eqtveawx")).toBe(true);
expect(
looksLikeNostrId("npub1cgspd9fhty7hzfhfsshnr2x56haxdcn3ecuk7ykact29tku9t0eqtveawx"),
).toBe(true);
});
it("recognizes hex pubkey", () => {
expect(looksLikeNostrId("c220169537593d7126e9842f31a8d4d5fa66e271ce396f12ddc2d455db855bf2")).toBe(true);
expect(
looksLikeNostrId("c220169537593d7126e9842f31a8d4d5fa66e271ce396f12ddc2d455db855bf2"),
).toBe(true);
});
it("rejects invalid formats", () => {
@@ -132,7 +138,7 @@ describe("createGiftWrap", () => {
it("creates a kind:1059 event", () => {
const { event, eventId } = createGiftWrap(recipientPk, "Hello!", senderSkBytes);
expect(event.kind).toBe(1059);
expect(event.id).toBe(eventId);
expect(eventId).toHaveLength(64);
@@ -140,15 +146,15 @@ describe("createGiftWrap", () => {
it("includes recipient in p-tag", () => {
const { event } = createGiftWrap(recipientPk, "Hello!", senderSkBytes);
const pTags = event.tags.filter(t => t[0] === "p");
const pTags = event.tags.filter((t) => t[0] === "p");
expect(pTags.length).toBeGreaterThan(0);
expect(pTags.some(t => t[1] === recipientPk)).toBe(true);
expect(pTags.some((t) => t[1] === recipientPk)).toBe(true);
});
it("has encrypted content", () => {
const { event } = createGiftWrap(recipientPk, "Secret message", senderSkBytes);
// Content should not contain the plaintext
expect(event.content).not.toContain("Secret message");
// Content should be non-empty (encrypted)
@@ -158,7 +164,7 @@ describe("createGiftWrap", () => {
it("uses ephemeral pubkey (not sender's)", () => {
const senderPk = getPublicKey(senderSkBytes);
const { event } = createGiftWrap(recipientPk, "Hello!", senderSkBytes);
// Gift wrap pubkey should be ephemeral, not the sender
expect(event.pubkey).not.toBe(senderPk);
});
@@ -166,7 +172,7 @@ describe("createGiftWrap", () => {
it("accepts npub format for recipient", () => {
const recipientNpub = "npub1cgspd9fhty7hzfhfsshnr2x56haxdcn3ecuk7ykact29tku9t0eqtveawx";
const { event } = createGiftWrap(recipientNpub, "Hello!", senderSkBytes);
expect(event.kind).toBe(1059);
});
});
@@ -180,9 +186,9 @@ describe("unwrapGiftWrap", () => {
it("unwraps a gift-wrapped message", () => {
const message = "Test message for NIP-17";
const { event } = createGiftWrap(recipientPk, message, senderSkBytes);
const unwrapped = unwrapGiftWrap(event, recipientSkBytes);
expect(unwrapped).not.toBeNull();
expect(unwrapped?.content).toBe(message);
expect(unwrapped?.senderPubkey).toBe(senderPk);
@@ -191,24 +197,24 @@ describe("unwrapGiftWrap", () => {
it("returns sender npub", () => {
const { event } = createGiftWrap(recipientPk, "Hello", senderSkBytes);
const unwrapped = unwrapGiftWrap(event, recipientSkBytes);
expect(unwrapped?.senderNpub).toMatch(/^npub1/);
});
it("returns event ID", () => {
const { event, eventId } = createGiftWrap(recipientPk, "Hello", senderSkBytes);
const unwrapped = unwrapGiftWrap(event, recipientSkBytes);
expect(unwrapped?.eventId).toBe(eventId);
});
it("fails to unwrap with wrong key", () => {
const message = "Secret message";
const { event } = createGiftWrap(recipientPk, message, senderSkBytes);
const wrongKey = hexToBytes("1111111111111111111111111111111111111111111111111111111111111111");
const unwrapped = unwrapGiftWrap(event, wrongKey);
expect(unwrapped).toBeNull();
});
@@ -222,8 +228,11 @@ describe("unwrapGiftWrap", () => {
id: "xxx",
sig: "yyy",
};
const result = unwrapGiftWrap(fakeEvent as any, recipientSkBytes);
const result = unwrapGiftWrap(
fakeEvent as unknown as import("nostr-tools").Event,
recipientSkBytes,
);
expect(result).toBeNull();
});
@@ -237,8 +246,11 @@ describe("unwrapGiftWrap", () => {
id: "xxx",
sig: "yyy",
};
const result = unwrapGiftWrap(fakeEvent as any, recipientSkBytes);
const result = unwrapGiftWrap(
fakeEvent as unknown as import("nostr-tools").Event,
recipientSkBytes,
);
expect(result).toBeNull();
});
@@ -246,7 +258,7 @@ describe("unwrapGiftWrap", () => {
const message = "Hello 👋 世界 🌍 مرحبا";
const { event } = createGiftWrap(recipientPk, message, senderSkBytes);
const unwrapped = unwrapGiftWrap(event, recipientSkBytes);
expect(unwrapped?.content).toBe(message);
});
@@ -254,7 +266,7 @@ describe("unwrapGiftWrap", () => {
const message = "x".repeat(10000);
const { event } = createGiftWrap(recipientPk, message, senderSkBytes);
const unwrapped = unwrapGiftWrap(event, recipientSkBytes);
expect(unwrapped?.content).toBe(message);
});
});
@@ -264,10 +276,10 @@ describe("roundtrip", () => {
const senderSkBytes = hexToBytes(TEST_SENDER_SK);
const recipientSkBytes = hexToBytes(TEST_RECIPIENT_SK);
const recipientPk = getPublicKey(recipientSkBytes);
const originalMessage = "Roundtrip test";
const { event } = createGiftWrap(recipientPk, originalMessage, senderSkBytes);
// Recipient unwraps
const unwrapped = unwrapGiftWrap(event, recipientSkBytes);
expect(unwrapped?.content).toBe(originalMessage);

View File

@@ -1,15 +1,15 @@
/**
* NIP-17 Gift Wrap Implementation
*
*
* Message structure:
* - Kind 14 (rumor): Unsigned chat message
* - Kind 13 (seal): Rumor encrypted to recipient, signed by sender
* - Kind 1059 (gift wrap): Seal encrypted with ephemeral key
*
*
* Benefits over NIP-04:
* - Metadata privacy: sender/recipient hidden from relays
* - Forward secrecy: ephemeral keys for each message
*
*
* @see https://github.com/nostr-protocol/nips/blob/master/17.md
*/
@@ -45,7 +45,7 @@ export interface WrapResult {
/**
* Create a NIP-17 gift-wrapped message
*
*
* @param recipientPubkey - Recipient's pubkey (hex or npub)
* @param content - Message content
* @param privateKeyBytes - Sender's private key as Uint8Array
@@ -91,7 +91,7 @@ export function createGiftWrap(
/**
* Unwrap a NIP-17 gift-wrapped message
*
*
* @param wrapEvent - Kind 1059 gift wrap event
* @param privateKeyBytes - Recipient's private key as Uint8Array
* @returns Unwrapped message or null if decryption fails
@@ -178,7 +178,7 @@ export function normalizeNostrTarget(target: string): string | null {
try {
const decoded = nip19.decode(target);
if (decoded.type === "npub") {
return decoded.data as string;
return decoded.data.toLowerCase();
}
} catch {
return null;

View File

@@ -2,8 +2,8 @@
* Tests for NIP-42 authentication
*/
import { describe, it, expect, beforeEach } from "vitest";
import { getPublicKey, verifyEvent } from "nostr-tools";
import { describe, it, expect, beforeEach } from "vitest";
import {
createAuthEvent,
parseAuthChallenge,
@@ -28,7 +28,7 @@ describe("createAuthEvent", () => {
it("creates a kind:22242 event", () => {
const { event, eventId } = createAuthEvent(challenge, relayUrl, TEST_SK_BYTES);
expect(event.kind).toBe(22242);
expect(eventId).toBe(event.id);
expect(eventId).toHaveLength(64);
@@ -36,16 +36,16 @@ describe("createAuthEvent", () => {
it("includes relay tag", () => {
const { event } = createAuthEvent(challenge, relayUrl, TEST_SK_BYTES);
const relayTag = event.tags.find(t => t[0] === "relay");
const relayTag = event.tags.find((t) => t[0] === "relay");
expect(relayTag).toBeDefined();
expect(relayTag?.[1]).toBe(relayUrl);
});
it("includes challenge tag", () => {
const { event } = createAuthEvent(challenge, relayUrl, TEST_SK_BYTES);
const challengeTag = event.tags.find(t => t[0] === "challenge");
const challengeTag = event.tags.find((t) => t[0] === "challenge");
expect(challengeTag).toBeDefined();
expect(challengeTag?.[1]).toBe(challenge);
});
@@ -69,7 +69,7 @@ describe("createAuthEvent", () => {
const before = Math.floor(Date.now() / 1000);
const { event } = createAuthEvent(challenge, relayUrl, TEST_SK_BYTES);
const after = Math.floor(Date.now() / 1000);
expect(event.created_at).toBeGreaterThanOrEqual(before);
expect(event.created_at).toBeLessThanOrEqual(after);
});
@@ -81,7 +81,7 @@ describe("parseAuthChallenge", () => {
it("parses valid AUTH message", () => {
const message = ["AUTH", "challenge-string-123"];
const result = parseAuthChallenge(message, relayUrl);
expect(result).not.toBeNull();
expect(result?.relay).toBe(relayUrl);
expect(result?.challenge).toBe("challenge-string-123");
@@ -101,9 +101,9 @@ describe("parseAuthChallenge", () => {
});
it("returns null for non-array input", () => {
expect(parseAuthChallenge("AUTH" as any, relayUrl)).toBeNull();
expect(parseAuthChallenge({} as any, relayUrl)).toBeNull();
expect(parseAuthChallenge(null as any, relayUrl)).toBeNull();
expect(parseAuthChallenge("AUTH" as unknown as unknown[], relayUrl)).toBeNull();
expect(parseAuthChallenge({} as unknown as unknown[], relayUrl)).toBeNull();
expect(parseAuthChallenge(null as unknown as unknown[], relayUrl)).toBeNull();
});
});
@@ -111,7 +111,7 @@ describe("createAuthMessage", () => {
it("creates AUTH message array", () => {
const { event } = createAuthEvent("challenge", "wss://relay.test", TEST_SK_BYTES);
const message = createAuthMessage(event);
expect(Array.isArray(message)).toBe(true);
expect(message[0]).toBe("AUTH");
expect(message[1]).toBe(event);
@@ -128,33 +128,56 @@ describe("createAuthHandler", () => {
describe("handleChallenge", () => {
it("creates auth response", () => {
const response = handler.handleChallenge("test-challenge", "wss://relay.test");
expect(response.event.kind).toBe(22242);
expect(response.eventId).toHaveLength(64);
});
it("marks relay as requiring auth", () => {
expect(handler.requiresAuth("wss://relay.test")).toBe(false);
handler.handleChallenge("challenge", "wss://relay.test");
expect(handler.requiresAuth("wss://relay.test")).toBe(true);
});
});
describe("signAuthEvent", () => {
it("signs AUTH event templates from nostr-tools", async () => {
const relay = "wss://relay.test";
const challenge = "challenge-123";
const template = {
kind: 22242,
created_at: Math.floor(Date.now() / 1000),
tags: [
["relay", relay],
["challenge", challenge],
],
content: "",
};
const signed = await handler.signAuthEvent(template);
expect(signed.kind).toBe(22242);
expect(signed.pubkey).toBe(TEST_PK);
expect(verifyEvent(signed)).toBe(true);
expect(handler.requiresAuth(relay)).toBe(true);
expect(handler.isAuthenticated(relay)).toBe(true);
});
});
describe("markAuthenticated / isAuthenticated", () => {
it("tracks authenticated relays", () => {
expect(handler.isAuthenticated("wss://relay.test")).toBe(false);
handler.markAuthenticated("wss://relay.test");
expect(handler.isAuthenticated("wss://relay.test")).toBe(true);
});
it("handles multiple relays", () => {
handler.markAuthenticated("wss://relay1.test");
handler.markAuthenticated("wss://relay2.test");
expect(handler.isAuthenticated("wss://relay1.test")).toBe(true);
expect(handler.isAuthenticated("wss://relay2.test")).toBe(true);
expect(handler.isAuthenticated("wss://relay3.test")).toBe(false);
@@ -204,7 +227,7 @@ describe("isAuthOk", () => {
describe("parseOkResponse", () => {
it("parses successful OK", () => {
const result = parseOkResponse(["OK", "event-123", true, "success"]);
expect(result).not.toBeNull();
expect(result?.eventId).toBe("event-123");
expect(result?.success).toBe(true);
@@ -213,7 +236,7 @@ describe("parseOkResponse", () => {
it("parses failed OK", () => {
const result = parseOkResponse(["OK", "event-456", false, "auth-required: need AUTH"]);
expect(result).not.toBeNull();
expect(result?.eventId).toBe("event-456");
expect(result?.success).toBe(false);
@@ -222,7 +245,7 @@ describe("parseOkResponse", () => {
it("handles missing message", () => {
const result = parseOkResponse(["OK", "event-789", true]);
expect(result).not.toBeNull();
expect(result?.message).toBe("");
});

View File

@@ -1,18 +1,18 @@
/**
* NIP-42 Authentication
*
*
* Handles relay authentication for relays that require it.
*
*
* Flow:
* 1. Relay sends AUTH challenge: ["AUTH", "<challenge>"]
* 2. Client signs kind:22242 event with challenge in tags
* 3. Client sends: ["AUTH", <signed-event>]
* 4. Relay verifies and grants access
*
*
* @see https://github.com/nostr-protocol/nips/blob/master/42.md
*/
import { finalizeEvent, type Event } from "nostr-tools";
import { finalizeEvent, type Event, type EventTemplate, type VerifiedEvent } from "nostr-tools";
import { makeAuthEvent as nostrToolsMakeAuthEvent } from "nostr-tools/nip42";
// ============================================================================
@@ -39,9 +39,9 @@ export interface AuthResponse {
/**
* Create a NIP-42 authentication event
*
*
* Uses nostr-tools' implementation for compatibility.
*
*
* @param challenge - The challenge string from the relay
* @param relayUrl - The relay URL requesting auth
* @param privateKeyBytes - User's private key as Uint8Array
@@ -54,7 +54,7 @@ export function createAuthEvent(
): AuthResponse {
// Use nostr-tools' makeAuthEvent for compatibility
const event = nostrToolsMakeAuthEvent(relayUrl, challenge);
// Sign the event
const signedEvent = finalizeEvent(event, privateKeyBytes);
@@ -70,15 +70,12 @@ export function createAuthEvent(
/**
* Parse an AUTH message from a relay
*
*
* @param message - Raw message from relay (parsed JSON)
* @param relayUrl - The relay URL
* @returns AuthChallenge if valid, null otherwise
*/
export function parseAuthChallenge(
message: unknown[],
relayUrl: string,
): AuthChallenge | null {
export function parseAuthChallenge(message: unknown[], relayUrl: string): AuthChallenge | null {
// AUTH message format: ["AUTH", "<challenge>"]
if (!Array.isArray(message)) {
return null;
@@ -101,7 +98,7 @@ export function parseAuthChallenge(
/**
* Create the AUTH response message to send to relay
*
*
* @param event - The signed auth event
* @returns Message array to send: ["AUTH", <event>]
*/
@@ -116,6 +113,8 @@ export function createAuthMessage(event: Event): unknown[] {
export interface AuthHandler {
/** Handle an AUTH challenge from a relay */
handleChallenge: (challenge: string, relayUrl: string) => AuthResponse;
/** Sign nostr-tools AUTH template events (SimplePool onauth callback) */
signAuthEvent: (eventTemplate: EventTemplate) => Promise<VerifiedEvent>;
/** Check if a relay requires auth (based on past challenges) */
requiresAuth: (relayUrl: string) => boolean;
/** Mark a relay as authenticated */
@@ -126,7 +125,7 @@ export interface AuthHandler {
/**
* Create an auth handler for a specific private key
*
*
* @param privateKeyBytes - User's private key
* @returns AuthHandler instance
*/
@@ -140,6 +139,29 @@ export function createAuthHandler(privateKeyBytes: Uint8Array): AuthHandler {
return createAuthEvent(challenge, relayUrl, privateKeyBytes);
},
async signAuthEvent(eventTemplate: EventTemplate): Promise<VerifiedEvent> {
let relayUrl: string | null = null;
let challenge: string | null = null;
for (const tag of eventTemplate.tags ?? []) {
if (tag[0] === "relay" && typeof tag[1] === "string" && tag[1].length > 0) {
relayUrl = tag[1];
} else if (tag[0] === "challenge" && typeof tag[1] === "string" && tag[1].length > 0) {
challenge = tag[1];
}
}
if (relayUrl && challenge) {
challengedRelays.add(relayUrl);
}
const signedEvent = finalizeEvent(eventTemplate, privateKeyBytes);
if (relayUrl) {
authenticatedRelays.add(relayUrl);
}
return signedEvent;
},
requiresAuth(relayUrl: string): boolean {
return challengedRelays.has(relayUrl);
},

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { isTruthyEnvValue } from "../../../src/infra/env.js";
import { fetchDmInboxRelays, fetchRelayList, getRelaysForDm } from "./nip65.js";
const LIVE =
isTruthyEnvValue(process.env.NOSTR_LIVE_TEST) ||
isTruthyEnvValue(process.env.LIVE) ||
isTruthyEnvValue(process.env.OPENCLAW_LIVE_TEST);
const describeLive = LIVE ? describe : describe.skip;
const TEST_PUBKEY =
process.env.NOSTR_LIVE_PUBKEY?.trim() ||
"c220169537593d7126e9842f31a8d4d5fa66e271ce396f12ddc2d455db855bf2";
describeLive("NIP-65 live relay discovery", () => {
it("queries public relays and returns sanitized relay lists", async () => {
const dmInboxRelays = await fetchDmInboxRelays(TEST_PUBKEY);
const relayList = await fetchRelayList(TEST_PUBKEY);
const resolvedRelays = await getRelaysForDm(TEST_PUBKEY, ["wss://relay.damus.io"]);
expect(Array.isArray(dmInboxRelays)).toBe(true);
expect(Array.isArray(relayList.read)).toBe(true);
expect(Array.isArray(relayList.write)).toBe(true);
expect(Array.isArray(relayList.all)).toBe(true);
expect(Array.isArray(resolvedRelays)).toBe(true);
for (const relay of [
...dmInboxRelays,
...relayList.read,
...relayList.write,
...relayList.all,
...resolvedRelays,
]) {
expect(relay.startsWith("wss://")).toBe(true);
expect(relay.includes("localhost")).toBe(false);
expect(relay.includes("127.0.0.1")).toBe(false);
}
}, 30_000);
});

View File

@@ -1,168 +1,246 @@
/**
* Tests for NIP-65 relay discovery
*
* Note: These are unit tests for the logic. Integration tests
* with real relays are in nip65.integration.test.ts
*/
import type { Filter } from "nostr-tools";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
clearRelayCache,
fetchDmInboxRelays,
fetchRelayList,
getRelaysForDm,
sanitizeRelayUrls,
} from "./nip65.js";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { clearRelayCache } from "./nip65.js";
interface SubscribeParams {
onevent?: (event: import("nostr-tools").Event) => void;
oneose?: () => void;
onclose?: () => void;
}
// We'll mock the relay queries for unit tests
// Real integration tests would hit actual relays
class FakePool {
public closeCount = 0;
public destroyCount = 0;
describe("NIP-65 relay discovery", () => {
constructor(
private readonly onSubscribe: (
filter: Filter,
params: SubscribeParams,
close: () => void,
) => void,
) {}
subscribeMany(_relays: string[], filter: Filter, params: SubscribeParams): { close: () => void } {
const close = () => {
this.closeCount += 1;
};
this.onSubscribe(filter, params, close);
return { close };
}
destroy(): void {
this.destroyCount += 1;
}
}
const TEST_PUBKEY = "c220169537593d7126e9842f31a8d4d5fa66e271ce396f12ddc2d455db855bf2";
describe("sanitizeRelayUrls", () => {
it("keeps valid public wss relays and deduplicates", () => {
const result = sanitizeRelayUrls([
"wss://relay.damus.io",
"wss://relay.damus.io/",
"wss://relay.primal.net?query=1",
"wss://nos.lol#hash",
]);
expect(result).toEqual(["wss://relay.damus.io", "wss://relay.primal.net", "wss://nos.lol"]);
});
it("drops unsafe and invalid relays", () => {
const result = sanitizeRelayUrls([
"ws://relay.example.com",
"https://relay.example.com",
"wss://localhost:7447",
"wss://127.0.0.1:7447",
"wss://10.0.0.4:7447",
"not-a-url",
"wss://relay.example.com",
]);
expect(result).toEqual(["wss://relay.example.com"]);
});
});
describe("fetchDmInboxRelays", () => {
beforeEach(() => {
// Clear cache between tests
clearRelayCache();
});
describe("cache behavior", () => {
it("clearRelayCache clears all caches", () => {
// This is a basic sanity test
clearRelayCache();
// No error means success
expect(true).toBe(true);
});
});
describe("getRelaysForDm logic", () => {
it.skip("returns fallback relays when discovery fails (requires network)", async () => {
// This test requires network access - skipped in unit tests
// Run integration tests for network behavior
const { getRelaysForDm } = await import("./nip65.js");
const fallback = ["wss://relay1.test", "wss://relay2.test"];
const result = await getRelaysForDm("invalid-pubkey", fallback);
expect(Array.isArray(result)).toBe(true);
it("parses kind:10050 relay tags and sanitizes output", async () => {
const pool = new FakePool((_filter, params) => {
setTimeout(() => {
params.onevent?.({
id: "x".repeat(64),
kind: 10050,
pubkey: TEST_PUBKEY,
created_at: 1,
tags: [
["relay", "wss://relay.example.com"],
["relay", "wss://relay.example.com/"],
["relay", "wss://localhost:7447"],
],
content: "",
sig: "y".repeat(128),
} as import("nostr-tools").Event);
params.oneose?.();
}, 0);
});
it("uses fallback when provided empty array", () => {
// Synchronous test - no network
const fallback = ["wss://relay.test"];
expect(fallback.length).toBe(1);
});
});
describe("relay URL handling", () => {
it("handles wss:// URLs correctly", () => {
// Test that relay URLs are validated
const validUrl = "wss://relay.example.com";
expect(validUrl.startsWith("wss://")).toBe(true);
});
it("rejects non-wss URLs", () => {
const invalidUrls = [
"http://relay.example.com",
"https://relay.example.com",
"ws://relay.example.com",
"relay.example.com",
];
for (const url of invalidUrls) {
expect(url.startsWith("wss://")).toBe(false);
}
});
});
});
describe("BOOTSTRAP_RELAYS", () => {
it.skip("includes essential relays (requires network)", async () => {
// This test requires network access - skipped in unit tests
const { getRelaysForDm } = await import("./nip65.js");
const result = await getRelaysForDm(
"0000000000000000000000000000000000000000000000000000000000000000",
[]
const relays = await fetchDmInboxRelays(
TEST_PUBKEY,
pool as unknown as import("nostr-tools").SimplePool,
);
expect(Array.isArray(result)).toBe(true);
expect(relays).toEqual(["wss://relay.example.com"]);
});
it("constants are valid wss URLs", () => {
// Test the URL format without network
const bootstrapRelays = [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.primal.net",
"wss://premium.primal.net",
"wss://purplepag.es",
];
for (const relay of bootstrapRelays) {
expect(relay.startsWith("wss://")).toBe(true);
expect(relay.length).toBeGreaterThan(10);
}
});
});
it("closes subscriptions when discovery times out", async () => {
vi.useFakeTimers();
const pool = new FakePool(() => {
// Intentionally do nothing so query timeout path is exercised.
});
describe("kind:10050 DM inbox relays", () => {
it.skip("fetchDmInboxRelays returns array (requires network)", async () => {
const { fetchDmInboxRelays } = await import("./nip65.js");
const result = await fetchDmInboxRelays(
"0000000000000000000000000000000000000000000000000000000000000000"
const pending = fetchDmInboxRelays(
TEST_PUBKEY,
pool as unknown as import("nostr-tools").SimplePool,
);
expect(Array.isArray(result)).toBe(true);
});
it("returns type matches expected interface", () => {
// Type-level test without network
type ExpectedReturn = Promise<string[]>;
const typeCheck: ExpectedReturn = Promise.resolve(["wss://test"]);
expect(typeCheck).toBeDefined();
vi.advanceTimersByTime(5000);
await pending;
expect(pool.closeCount).toBe(1);
vi.useRealTimers();
});
});
describe("kind:10002 relay list", () => {
it.skip("fetchRelayList returns structured result (requires network)", async () => {
const { fetchRelayList } = await import("./nip65.js");
const result = await fetchRelayList(
"0000000000000000000000000000000000000000000000000000000000000000"
describe("fetchRelayList", () => {
beforeEach(() => {
clearRelayCache();
});
it("parses read/write relays from kind:10002 event and sanitizes", async () => {
const pool = new FakePool((_filter, params) => {
setTimeout(() => {
params.onevent?.({
id: "a".repeat(64),
kind: 10002,
pubkey: TEST_PUBKEY,
created_at: 1,
tags: [
["r", "wss://relay-read.example.com", "read"],
["r", "wss://relay-write.example.com", "write"],
["r", "wss://relay-both.example.com"],
["r", "wss://127.0.0.1:7447", "write"],
],
content: "",
sig: "b".repeat(128),
} as import("nostr-tools").Event);
params.oneose?.();
}, 0);
});
const relayList = await fetchRelayList(
TEST_PUBKEY,
pool as unknown as import("nostr-tools").SimplePool,
);
expect(result).toHaveProperty("read");
expect(result).toHaveProperty("write");
expect(result).toHaveProperty("all");
});
it("result type has expected structure", () => {
// Type-level test without network
interface ExpectedResult {
read: string[];
write: string[];
all: string[];
}
const typeCheck: ExpectedResult = { read: [], write: [], all: [] };
expect(typeCheck.read).toEqual([]);
expect(typeCheck.write).toEqual([]);
expect(typeCheck.all).toEqual([]);
expect(relayList.read).toEqual([
"wss://relay-read.example.com",
"wss://relay-both.example.com",
]);
expect(relayList.write).toEqual([
"wss://relay-write.example.com",
"wss://relay-both.example.com",
]);
expect(relayList.all).toEqual([
"wss://relay-read.example.com",
"wss://relay-write.example.com",
"wss://relay-both.example.com",
]);
});
});
describe("getWriteRelays", () => {
it.skip("returns array of relays (requires network)", async () => {
const { getWriteRelays } = await import("./nip65.js");
const fallback = ["wss://fallback.test"];
const result = await getWriteRelays(
"0000000000000000000000000000000000000000000000000000000000000000",
fallback
describe("getRelaysForDm", () => {
beforeEach(() => {
clearRelayCache();
});
it("prefers DM inbox relays over write relays", async () => {
const pool = new FakePool((filter, params) => {
setTimeout(() => {
if (filter.kinds?.includes(10050)) {
params.onevent?.({
id: "m".repeat(64),
kind: 10050,
pubkey: TEST_PUBKEY,
created_at: 2,
tags: [["relay", "wss://relay-dm.example.com"]],
content: "",
sig: "n".repeat(128),
} as import("nostr-tools").Event);
} else if (filter.kinds?.includes(10002)) {
params.onevent?.({
id: "o".repeat(64),
kind: 10002,
pubkey: TEST_PUBKEY,
created_at: 1,
tags: [["r", "wss://relay-write.example.com", "write"]],
content: "",
sig: "p".repeat(128),
} as import("nostr-tools").Event);
}
params.oneose?.();
}, 0);
});
const relays = await getRelaysForDm(
TEST_PUBKEY,
["wss://relay-fallback.example.com"],
pool as unknown as import("nostr-tools").SimplePool,
);
expect(Array.isArray(result)).toBe(true);
expect(relays).toEqual(["wss://relay-dm.example.com"]);
});
it("fallback parameter is used correctly", () => {
// Test the fallback behavior conceptually
const fallback = ["wss://fallback1.test", "wss://fallback2.test"];
// If discovery fails, fallback should be returned
// This tests the expected behavior without network
expect(fallback.length).toBe(2);
expect(fallback[0]).toMatch(/^wss:\/\//);
it("falls back to configured relays when discovered relays are unsafe", async () => {
const pool = new FakePool((filter, params) => {
setTimeout(() => {
if (filter.kinds?.includes(10050)) {
params.onevent?.({
id: "q".repeat(64),
kind: 10050,
pubkey: TEST_PUBKEY,
created_at: 2,
tags: [["relay", "wss://localhost:7447"]],
content: "",
sig: "r".repeat(128),
} as import("nostr-tools").Event);
} else if (filter.kinds?.includes(10002)) {
params.onevent?.({
id: "s".repeat(64),
kind: 10002,
pubkey: TEST_PUBKEY,
created_at: 1,
tags: [["r", "wss://127.0.0.1:7447", "write"]],
content: "",
sig: "t".repeat(128),
} as import("nostr-tools").Event);
}
params.oneose?.();
}, 0);
});
const fallback = ["wss://relay-fallback.example.com"];
const relays = await getRelaysForDm(
TEST_PUBKEY,
fallback,
pool as unknown as import("nostr-tools").SimplePool,
);
expect(relays).toEqual(fallback);
});
});

View File

@@ -1,13 +1,13 @@
/**
* NIP-65 Relay List Metadata + NIP-17 DM Inbox Relays
*
*
* Fetches recipient's preferred relays before sending DMs.
*
*
* Priority for DM delivery:
* 1. kind:10050 — DM inbox relays (NIP-17 specific)
* 2. kind:10002 — General relay list (write relays)
* 3. Fallback to configured relays
*
*
* @see https://github.com/nostr-protocol/nips/blob/master/65.md
*/
@@ -51,6 +51,101 @@ interface RelayList {
all: string[];
}
function isIpv4Address(hostname: string): boolean {
return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(hostname);
}
function isPrivateIpv4(hostname: string): boolean {
const parts = hostname.split(".").map((part) => Number.parseInt(part, 10));
if (parts.length !== 4 || parts.some((part) => Number.isNaN(part) || part < 0 || part > 255)) {
return true;
}
const [a, b] = parts;
if (a === 10 || a === 127 || a === 0) {
return true;
}
if (a === 192 && b === 168) {
return true;
}
if (a === 172 && b >= 16 && b <= 31) {
return true;
}
if (a === 169 && b === 254) {
return true;
}
if (a >= 224) {
return true;
}
return false;
}
function isPrivateOrLocalHost(hostname: string): boolean {
const normalized = hostname.trim().toLowerCase();
if (!normalized) {
return true;
}
if (normalized === "localhost" || normalized.endsWith(".localhost")) {
return true;
}
if (normalized.includes(":")) {
if (normalized === "::1") {
return true;
}
if (
normalized.startsWith("fc") ||
normalized.startsWith("fd") ||
normalized.startsWith("fe80:")
) {
return true;
}
return false;
}
if (isIpv4Address(normalized)) {
return isPrivateIpv4(normalized);
}
return false;
}
function normalizeRelayUrl(url: string): string | null {
try {
const parsed = new URL(url);
if (parsed.protocol !== "wss:") {
return null;
}
if (isPrivateOrLocalHost(parsed.hostname)) {
return null;
}
// Normalize for dedupe: drop query/hash and trailing slash.
parsed.search = "";
parsed.hash = "";
const normalized = parsed.toString();
return normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
} catch {
return null;
}
}
export function sanitizeRelayUrls(relays: string[]): string[] {
const unique = new Set<string>();
for (const relay of relays) {
const normalized = normalizeRelayUrl(relay);
if (!normalized) {
continue;
}
unique.add(normalized);
}
return Array.from(unique);
}
function createScopedPool(pool?: SimplePool): { pool: SimplePool; ownsPool: boolean } {
if (pool) {
return { pool, ownsPool: false };
}
return { pool: new SimplePool(), ownsPool: true };
}
// ============================================================================
// Cache
// ============================================================================
@@ -92,39 +187,46 @@ async function queryRelays(
timeoutMs: number = QUERY_TIMEOUT_MS,
): Promise<import("nostr-tools").Event[]> {
const events: import("nostr-tools").Event[] = [];
return new Promise((resolve) => {
const timeout = setTimeout(() => {
resolve(events);
}, timeoutMs);
// subscribeMany expects (relays, filters[], opts)
const sub = pool.subscribeMany(relays, [filter] as Filter[], {
onevent: (event: import("nostr-tools").Event) => {
events.push(event);
},
oneose: () => {
clearTimeout(timeout);
sub.close();
resolve(events);
},
});
return new Promise((resolve) => {
let settled = false;
let sub: { close: () => void } | null = null;
const finish = () => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
sub?.close();
resolve(events);
};
const timeout = setTimeout(finish, timeoutMs);
try {
sub = pool.subscribeMany(relays, filter, {
onevent: (event: import("nostr-tools").Event) => {
events.push(event);
},
oneose: finish,
onclose: () => finish(),
});
} catch {
finish();
}
});
}
/**
* Fetch user's DM inbox relays (kind:10050)
*
*
* These are the relays where the user wants to receive DMs.
*
*
* @param pubkey - User's hex pubkey
* @param pool - SimplePool instance (optional, creates one if not provided)
* @returns Array of relay URLs
*/
export async function fetchDmInboxRelays(
pubkey: string,
pool?: SimplePool,
): Promise<string[]> {
export async function fetchDmInboxRelays(pubkey: string, pool?: SimplePool): Promise<string[]> {
// Check cache
const cacheKey = `dm:${pubkey}`;
const cached = getCached(relayCache, cacheKey);
@@ -132,8 +234,8 @@ export async function fetchDmInboxRelays(
return cached;
}
const usePool = pool ?? new SimplePool();
const relays: string[] = [];
const { pool: usePool, ownsPool } = createScopedPool(pool);
const relayCandidates: string[] = [];
try {
const events = await queryRelays(usePool, BOOTSTRAP_RELAYS, {
@@ -149,15 +251,21 @@ export async function fetchDmInboxRelays(
// Extract relay URLs from 'relay' tags
for (const tag of event.tags) {
if (tag[0] === "relay" && tag[1]) {
relays.push(tag[1]);
if (tag[0] === "relay" && typeof tag[1] === "string") {
relayCandidates.push(tag[1]);
}
}
}
} catch {
// Ignore errors, return empty
} finally {
if (ownsPool) {
usePool.destroy();
}
}
const relays = sanitizeRelayUrls(relayCandidates);
// Cache result (even if empty)
setCache(relayCache, cacheKey, relays);
return relays;
@@ -165,15 +273,12 @@ export async function fetchDmInboxRelays(
/**
* Fetch user's general relay list (kind:10002)
*
*
* @param pubkey - User's hex pubkey
* @param pool - SimplePool instance (optional)
* @returns Object with read, write, and all relay arrays
*/
export async function fetchRelayList(
pubkey: string,
pool?: SimplePool,
): Promise<RelayList> {
export async function fetchRelayList(pubkey: string, pool?: SimplePool): Promise<RelayList> {
// Check cache
const cacheKey = `list:${pubkey}`;
const cached = getCached(relayListCache, cacheKey);
@@ -181,8 +286,11 @@ export async function fetchRelayList(
return cached;
}
const usePool = pool ?? new SimplePool();
const { pool: usePool, ownsPool } = createScopedPool(pool);
const result: RelayList = { read: [], write: [], all: [] };
const readSet = new Set<string>();
const writeSet = new Set<string>();
const allSet = new Set<string>();
try {
const events = await queryRelays(usePool, BOOTSTRAP_RELAYS, {
@@ -196,34 +304,40 @@ export async function fetchRelayList(
events.sort((a, b) => b.created_at - a.created_at);
const event = events[0];
const seen = new Set<string>();
for (const tag of event.tags) {
if (tag[0] === "r" && tag[1]) {
const url = tag[1];
if (tag[0] === "r" && typeof tag[1] === "string") {
const url = normalizeRelayUrl(tag[1]);
if (!url) {
continue;
}
const marker = tag[2];
if (marker === "read") {
result.read.push(url);
readSet.add(url);
} else if (marker === "write") {
result.write.push(url);
writeSet.add(url);
} else {
// No marker = both read and write
result.read.push(url);
result.write.push(url);
readSet.add(url);
writeSet.add(url);
}
if (!seen.has(url)) {
result.all.push(url);
seen.add(url);
}
allSet.add(url);
}
}
}
} catch {
// Ignore errors, return empty
} finally {
if (ownsPool) {
usePool.destroy();
}
}
result.read = Array.from(readSet);
result.write = Array.from(writeSet);
result.all = Array.from(allSet);
// Cache result
setCache(relayListCache, cacheKey, result);
return result;
@@ -231,12 +345,12 @@ export async function fetchRelayList(
/**
* Get optimal relays for sending a DM to a pubkey
*
*
* Priority:
* 1. kind:10050 DM inbox relays (most specific)
* 2. kind:10002 write relays (general preference)
* 3. Fallback relays (configured defaults)
*
*
* @param recipientPubkey - Recipient's hex pubkey
* @param fallbackRelays - Relays to use if discovery fails
* @param pool - SimplePool instance (optional)
@@ -265,7 +379,7 @@ export async function getRelaysForDm(
/**
* Get user's write relays (for fetching their events like kind:3)
*
*
* @param pubkey - User's hex pubkey
* @param fallbackRelays - Relays to use if discovery fails
* @param pool - SimplePool instance (optional)

View File

@@ -0,0 +1,210 @@
import type { EventTemplate } from "nostr-tools";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
return {
subscribeMany: vi.fn(),
publish: vi.fn(),
getRelaysForDm: vi.fn(),
readNostrBusState: vi.fn(),
writeNostrBusState: vi.fn(),
computeSinceTimestamp: vi.fn(),
readNostrProfileState: vi.fn(),
writeNostrProfileState: vi.fn(),
publishProfile: vi.fn(),
getPublicKey: vi.fn(),
finalizeEvent: vi.fn(),
verifyEvent: vi.fn(),
encrypt: vi.fn(),
decrypt: vi.fn(),
createGiftWrap: vi.fn(),
unwrapGiftWrap: vi.fn(),
};
});
vi.mock("nostr-tools", async (importOriginal) => {
const actual = await importOriginal<typeof import("nostr-tools")>();
class MockSimplePool {
subscribeMany = mocks.subscribeMany;
publish = mocks.publish;
destroy = vi.fn();
}
return {
...actual,
SimplePool: MockSimplePool,
getPublicKey: mocks.getPublicKey,
finalizeEvent: mocks.finalizeEvent,
verifyEvent: mocks.verifyEvent,
};
});
vi.mock("nostr-tools/nip04", () => ({
encrypt: mocks.encrypt,
decrypt: mocks.decrypt,
}));
vi.mock("./nip17.js", () => ({
createGiftWrap: mocks.createGiftWrap,
unwrapGiftWrap: mocks.unwrapGiftWrap,
}));
vi.mock("./nip65.js", () => ({
getRelaysForDm: mocks.getRelaysForDm,
}));
vi.mock("./nostr-state-store.js", () => ({
readNostrBusState: mocks.readNostrBusState,
writeNostrBusState: mocks.writeNostrBusState,
computeSinceTimestamp: mocks.computeSinceTimestamp,
readNostrProfileState: mocks.readNostrProfileState,
writeNostrProfileState: mocks.writeNostrProfileState,
}));
vi.mock("./nostr-profile.js", () => ({
publishProfile: mocks.publishProfile,
}));
import { startNostrBus } from "./nostr-bus.js";
const TEST_HEX_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const BOT_PUBKEY = "a".repeat(64);
const TARGET_PUBKEY = "b".repeat(64);
function makeSignedEvent(template: EventTemplate) {
return {
...template,
id: "c".repeat(64),
pubkey: BOT_PUBKEY,
sig: "d".repeat(128),
};
}
describe("startNostrBus protocol flow", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getPublicKey.mockReturnValue(BOT_PUBKEY);
mocks.verifyEvent.mockReturnValue(true);
mocks.finalizeEvent.mockImplementation((template: EventTemplate) => makeSignedEvent(template));
mocks.encrypt.mockReturnValue("ciphertext");
mocks.decrypt.mockReturnValue("plaintext");
mocks.createGiftWrap.mockImplementation((toPubkey: string, text: string) => ({
event: {
id: "e".repeat(64),
kind: 1059,
pubkey: "f".repeat(64),
created_at: Math.floor(Date.now() / 1000),
tags: [["p", toPubkey]],
content: `wrapped:${text}`,
sig: "1".repeat(128),
},
eventId: "e".repeat(64),
}));
mocks.unwrapGiftWrap.mockReturnValue(null);
mocks.readNostrBusState.mockResolvedValue(null);
mocks.writeNostrBusState.mockResolvedValue(undefined);
mocks.computeSinceTimestamp.mockReturnValue(0);
mocks.readNostrProfileState.mockResolvedValue(null);
mocks.writeNostrProfileState.mockResolvedValue(undefined);
mocks.publishProfile.mockResolvedValue({
eventId: "p".repeat(64),
createdAt: 1,
successes: [],
failures: [],
});
mocks.getRelaysForDm.mockImplementation(async (_pubkey: string, fallbackRelays: string[]) => {
return fallbackRelays;
});
mocks.subscribeMany.mockImplementation(() => ({
close: vi.fn(),
}));
mocks.publish.mockImplementation(() => [Promise.resolve("ok")]);
});
it("subscribes to both NIP-04 and NIP-17 inbound DM kinds", async () => {
let capturedFilter: unknown;
mocks.subscribeMany.mockImplementation((_relays: string[], filter: unknown) => {
capturedFilter = filter;
return { close: vi.fn() };
});
const bus = await startNostrBus({
privateKey: TEST_HEX_KEY,
relays: ["wss://relay.test"],
onMessage: async () => {},
});
expect(capturedFilter).toEqual({
kinds: [4, 1059],
"#p": [BOT_PUBKEY],
since: 0,
});
bus.close();
});
it("passes an auth signer to publish for auth-required relays", async () => {
const authRelay = "wss://relay-auth.test";
const signedAuthEvents: Array<ReturnType<typeof makeSignedEvent>> = [];
mocks.getRelaysForDm.mockResolvedValue([authRelay]);
mocks.publish.mockImplementation(
(
relays: string[],
event: { kind: number; tags: string[][] },
params: {
onauth?: (template: EventTemplate) => Promise<ReturnType<typeof makeSignedEvent>>;
},
) => {
return [
(async () => {
expect(relays).toEqual([authRelay]);
expect(event.kind).toBe(4);
expect(params.onauth).toBeTypeOf("function");
const signedAuthEvent = await params.onauth!({
kind: 22242,
created_at: 1,
tags: [
["relay", authRelay],
["challenge", "challenge-token"],
],
content: "",
});
signedAuthEvents.push(signedAuthEvent);
return "ok";
})(),
];
},
);
const bus = await startNostrBus({
privateKey: TEST_HEX_KEY,
relays: ["wss://relay-configured.test"],
dmProtocol: "nip04",
onMessage: async () => {},
});
await bus.sendDm(TARGET_PUBKEY, "hello");
expect(mocks.getRelaysForDm).toHaveBeenCalledWith(
TARGET_PUBKEY,
["wss://relay-configured.test"],
expect.anything(),
);
expect(mocks.publish).toHaveBeenCalledTimes(1);
expect(signedAuthEvents).toHaveLength(1);
expect(signedAuthEvents[0].kind).toBe(22242);
expect(signedAuthEvents[0].pubkey).toBe(BOT_PUBKEY);
expect(signedAuthEvents[0].id).toHaveLength(64);
expect(signedAuthEvents[0].sig).toHaveLength(128);
bus.close();
});
});

View File

@@ -1,3 +1,4 @@
import { nip19 } from "nostr-tools";
import { describe, expect, it } from "vitest";
import {
validatePrivateKey,
@@ -157,6 +158,14 @@ describe("normalizePubkey", () => {
expect(() => normalizePubkey("invalid")).toThrow("Pubkey must be 64 hex characters");
});
});
describe("npub format", () => {
it("decodes npub to lowercase hex", () => {
const hex = "c220169537593d7126e9842f31a8d4d5fa66e271ce396f12ddc2d455db855bf2";
const npub = nip19.npubEncode(hex);
expect(normalizePubkey(npub)).toBe(hex);
});
});
});
describe("getPublicKeyFromPrivate", () => {

View File

@@ -7,9 +7,6 @@ import {
type Event,
} from "nostr-tools";
import { decrypt as nip04Decrypt, encrypt as nip04Encrypt } from "nostr-tools/nip04";
import { createGiftWrap, unwrapGiftWrap } from "./nip17.js";
import { getRelaysForDm } from "./nip65.js";
import { createAuthHandler, type AuthHandler } from "./nip42.js";
import type { NostrProfile } from "./config-schema.js";
import {
createMetrics,
@@ -18,6 +15,9 @@ import {
type MetricsSnapshot,
type MetricEvent,
} from "./metrics.js";
import { createGiftWrap, unwrapGiftWrap } from "./nip17.js";
import { createAuthHandler } from "./nip42.js";
import { getRelaysForDm } from "./nip65.js";
import { publishProfile as publishProfileFn, type ProfilePublishResult } from "./nostr-profile.js";
import {
readNostrBusState,
@@ -212,6 +212,10 @@ interface RelayHealthTracker {
getSortedRelays: (relays: string[]) => string[];
}
type RelayAuthSigner = (
eventTemplate: import("nostr-tools").EventTemplate,
) => Promise<import("nostr-tools").VerifiedEvent>;
function createRelayHealthTracker(): RelayHealthTracker {
const stats = new Map<string, RelayHealthStats>();
@@ -275,7 +279,7 @@ function createRelayHealthTracker(): RelayHealthTracker {
},
getSortedRelays(relays: string[]): string[] {
return [...relays].sort((a, b) => this.getScore(b) - this.getScore(a));
return [...relays].toSorted((a, b) => this.getScore(b) - this.getScore(a));
},
};
}
@@ -325,7 +329,10 @@ export function getPublicKeyFromPrivate(privateKey: string): string {
// ============================================================================
/**
* Start the Nostr DM bus - subscribes to NIP-04 encrypted DMs
* Start the Nostr DM bus.
*
* Inbound reads both NIP-04 and NIP-17 for compatibility during migration.
* Outbound uses the configured `dmProtocol`.
*/
export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusHandle> {
const {
@@ -339,7 +346,7 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH
maxSeenEntries = 100_000,
seenTtlMs = 60 * 60 * 1000,
} = options;
const useNip17 = dmProtocol === "nip17";
const sk = validatePrivateKey(privateKey);
@@ -351,13 +358,7 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH
// NIP-42 auth handler for relays that require authentication
// SimplePool accepts an onauth callback that signs AUTH events
const authHandler = createAuthHandler(sk);
// Auth signer function for SimplePool - signs AUTH events when challenged
const onauth = async (challenge: string, relay: string) => {
const response = authHandler.handleChallenge(challenge, relay);
authHandler.markAuthenticated(relay);
return response.event;
};
const onauth: RelayAuthSigner = authHandler.signAuthEvent;
// Initialize metrics
const metrics = onMetric ? createMetrics(onMetric) : createNoopMetrics();
@@ -459,11 +460,15 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH
onError?.(new Error("Invalid signature"), `event ${event.id}`);
return;
}
// Self-message loop prevention
// For NIP-04: check event.pubkey
// For NIP-17: check after unwrapping (sender is inside the gift wrap)
if (!useNip17 && event.pubkey === pk) {
if (event.kind !== 4 && event.kind !== 1059) {
metrics.emit("event.rejected.wrong_kind");
return;
}
// For NIP-04, sender pubkey is on outer event.
// For NIP-17, sender pubkey is only available after unwrap.
if (event.kind === 4 && event.pubkey === pk) {
metrics.emit("event.rejected.self_message");
return;
}
@@ -472,11 +477,11 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH
seen.add(event.id);
metrics.emit("memory.seen_tracker_size", seen.size());
// Decrypt the message based on protocol
// Decrypt based on event kind.
let plaintext: string;
let senderPubkey: string;
if (useNip17 && event.kind === 1059) {
if (event.kind === 1059) {
// NIP-17 gift-wrapped message
try {
const unwrapped = unwrapGiftWrap(event, sk);
@@ -487,13 +492,13 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH
}
plaintext = unwrapped.content;
senderPubkey = unwrapped.senderPubkey;
// Self-message check for NIP-17 (after unwrapping)
if (senderPubkey === pk) {
metrics.emit("event.rejected.self_message");
return;
}
metrics.emit("decrypt.success");
} catch (err) {
metrics.emit("decrypt.failure");
@@ -547,13 +552,11 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH
}
}
// Subscribe to DMs based on protocol
// NIP-17: kind 1059 (gift wrap) - sender is hidden, we're in the p-tag
// NIP-04: kind 4 (encrypted DM) - we're in the p-tag
const dmKinds = useNip17 ? [1059] : [4];
const dmFilter = { kinds: dmKinds, "#p": [pk], since };
const sub = pool.subscribeMany(relays, [dmFilter] as import("nostr-tools").Filter[], {
// Always subscribe to both kinds during migration:
// - kind 4 (legacy NIP-04)
// - kind 1059 (NIP-17 gift wrap)
const dmFilter: import("nostr-tools").Filter = { kinds: [4, 1059], "#p": [pk], since };
const sub = pool.subscribeMany(relays, dmFilter, {
onevent: handleEvent,
oneose: () => {
// EOSE handler - called when all stored events have been received
@@ -659,7 +662,7 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH
/**
* Send an encrypted DM to a pubkey
*
*
* Uses NIP-65 to discover recipient's preferred relays before sending.
*/
async function sendEncryptedDm(
@@ -673,7 +676,7 @@ async function sendEncryptedDm(
circuitBreakers: Map<string, CircuitBreaker>,
healthTracker: RelayHealthTracker,
onError?: (error: Error, context: string) => void,
onauth?: (challenge: string, relay: string) => Promise<Event>,
onauth?: RelayAuthSigner,
): Promise<void> {
// NIP-65: Discover recipient's preferred relays
let relays: string[];
@@ -688,7 +691,7 @@ async function sendEncryptedDm(
// Create the DM event based on protocol
let dmEvent: Event;
if (useNip17) {
// NIP-17: Gift-wrapped message
const { event } = createGiftWrap(toPubkey, text, sk);
@@ -729,8 +732,7 @@ async function sendEncryptedDm(
const startTime = Date.now();
try {
// oxlint-disable-next-line typescript/await-thenable typescript/no-floating-promises
await pool.publish([relay], dmEvent, { onauth });
await Promise.all(pool.publish([relay], dmEvent, { onauth }));
const latency = Date.now() - startTime;
// Record success
@@ -793,10 +795,10 @@ export function normalizePubkey(input: string): string {
if (decoded.type !== "npub") {
throw new Error("Invalid npub key");
}
// Convert Uint8Array to hex string
return Array.from(decoded.data as Uint8Array)
.map((b: number) => b.toString(16).padStart(2, "0"))
.join("");
if (typeof decoded.data !== "string" || !/^[0-9a-fA-F]{64}$/.test(decoded.data)) {
throw new Error("Invalid npub key");
}
return decoded.data.toLowerCase();
}
// Already hex - validate and return lowercase