mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-03 03:03:24 -04:00
fix(nostr): validate relay/auth config and add coverage
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
39
extensions/nostr/src/nip65.integration.test.ts
Normal file
39
extensions/nostr/src/nip65.integration.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
210
extensions/nostr/src/nostr-bus.flow.test.ts
Normal file
210
extensions/nostr/src/nostr-bus.flow.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user