From 31b05383378953d281b8c37dbe1126bc8b33b0b3 Mon Sep 17 00:00:00 2001 From: ennmichael Date: Sun, 9 Apr 2023 00:09:05 +0200 Subject: [PATCH 1/4] nip-07 support --- packages/nostr/src/crypto.ts | 1 - packages/nostr/src/event/direct-message.ts | 29 +++++++++++++++++++--- packages/nostr/src/event/index.ts | 7 ++++-- packages/nostr/src/nostr-object.ts | 20 +++++++++++++++ 4 files changed, 50 insertions(+), 7 deletions(-) create mode 100644 packages/nostr/src/nostr-object.ts diff --git a/packages/nostr/src/crypto.ts b/packages/nostr/src/crypto.ts index fe167c6d..caee0388 100644 --- a/packages/nostr/src/crypto.ts +++ b/packages/nostr/src/crypto.ts @@ -1,7 +1,6 @@ import * as secp from "@noble/secp256k1" import base64 from "base64-js" import { bech32 } from "bech32" -import { NostrError } from "./common" // TODO Use toHex as well as toString? Might be more explicit // Or maybe replace toString with toHex diff --git a/packages/nostr/src/event/direct-message.ts b/packages/nostr/src/event/direct-message.ts index c90111d5..e08df8c6 100644 --- a/packages/nostr/src/event/direct-message.ts +++ b/packages/nostr/src/event/direct-message.ts @@ -50,8 +50,24 @@ export async function createDirectMessage( ): Promise { recipient = parsePublicKey(recipient) if (priv === undefined) { - // TODO Use NIP-07 - throw new NostrError("todo") + if ( + typeof window === "undefined" || + window.nostr?.nip04?.encrypt === undefined + ) { + throw new NostrError("private key not specified") + } + const content = await window.nostr.nip04.encrypt(recipient, message) + return await signEvent( + { + kind: EventKind.DirectMessage, + tags: [["p", recipient]], + content, + getMessage, + getRecipient, + getPrevious, + }, + priv + ) } else { priv = parsePrivateKey(priv) const { data, iv } = await aesEncryptBase64(priv, recipient, message) @@ -81,8 +97,13 @@ export async function getMessage( throw new NostrError(`invalid direct message content ${this.content}`) } if (priv === undefined) { - // TODO Try to use NIP-07 - throw new NostrError("todo") + if ( + typeof window === "undefined" || + window.nostr?.nip04?.decrypt === undefined + ) { + throw new NostrError("private key not specified") + } + return await window.nostr.nip04.decrypt(this.pubkey, this.content) } else if (getPublicKey(priv) === this.getRecipient()) { return await aesDecryptBase64(this.pubkey, priv, { data, iv }) } diff --git a/packages/nostr/src/event/index.ts b/packages/nostr/src/event/index.ts index 535e7c71..cb0aa5d4 100644 --- a/packages/nostr/src/event/index.ts +++ b/packages/nostr/src/event/index.ts @@ -22,6 +22,7 @@ import { } from "./direct-message" import { ContactList, getContacts } from "./contact-list" import { Deletion, getEvents } from "./deletion" +import "../nostr-object" // TODO Add remaining event types @@ -135,8 +136,10 @@ export async function signEvent( event.sig = await schnorrSign(id, priv) return event as T } else { - // TODO Try to use NIP-07, otherwise throw - throw new NostrError("todo") + if (typeof window === "undefined" || window.nostr === undefined) { + throw new NostrError("no private key provided") + } + return await window.nostr.signEvent(event) } } diff --git a/packages/nostr/src/nostr-object.ts b/packages/nostr/src/nostr-object.ts new file mode 100644 index 00000000..3f837f70 --- /dev/null +++ b/packages/nostr/src/nostr-object.ts @@ -0,0 +1,20 @@ +import { PublicKey } from "./crypto" +import { RawEvent, Unsigned } from "./event" + +declare global { + interface Window { + nostr?: { + getPublicKey: () => Promise + signEvent: (event: Unsigned) => Promise + + getRelays?: () => Promise<{ + [url: string]: { read: boolean; write: boolean } + }> + + nip04?: { + encrypt?: (pubkey: PublicKey, plaintext: string) => Promise + decrypt?: (pubkey: PublicKey, ciphertext: string) => Promise + } + } + } +} From 3ffe4d5b19dd3a1be9c458bd62cb1a4f8e5f8b10 Mon Sep 17 00:00:00 2001 From: ennmichael Date: Sun, 9 Apr 2023 19:12:49 +0200 Subject: [PATCH 2/4] tests --- packages/nostr/package.json | 5 +- packages/nostr/src/event/index.ts | 14 ++++- packages/nostr/src/nostr-object.ts | 1 + packages/nostr/test/browser/index.html | 16 +++++ packages/nostr/test/browser/server.ts | 2 +- packages/nostr/test/setup.ts | 63 +++++++++++++++++-- .../{test.dm.ts => test.direct-message.ts} | 2 +- 7 files changed, 94 insertions(+), 9 deletions(-) rename packages/nostr/test/{test.dm.ts => test.direct-message.ts} (99%) diff --git a/packages/nostr/package.json b/packages/nostr/package.json index fa27def8..e31e2d21 100644 --- a/packages/nostr/package.json +++ b/packages/nostr/package.json @@ -4,8 +4,9 @@ "main": "dist/lib.js", "types": "dist/src/index.d.ts", "scripts": { - "build": "webpack", - "watch": "webpack -w", + "build": "rm -rf dist && webpack", + "watch": "rm -rf dist && webpack -w", + "clean": "rm -rf dist", "test": "ts-mocha --type-check -j 1 --timeout 5s test/test.*.ts", "test-browser": "ts-node test/browser/server.ts", "lint": "eslint ." diff --git a/packages/nostr/src/event/index.ts b/packages/nostr/src/event/index.ts index cb0aa5d4..8a3d853f 100644 --- a/packages/nostr/src/event/index.ts +++ b/packages/nostr/src/event/index.ts @@ -139,7 +139,19 @@ export async function signEvent( if (typeof window === "undefined" || window.nostr === undefined) { throw new NostrError("no private key provided") } - return await window.nostr.signEvent(event) + // Extensions like nos2x expect to receive only the event data, without any of the methods. + const methods: { [key: string]: unknown } = {} + for (const [key, value] of Object.entries(event)) { + if (typeof value === "function") { + methods[key] = value + delete event[key] + } + } + const signed = await window.nostr.signEvent(event) + return { + ...signed, + ...methods, + } } } diff --git a/packages/nostr/src/nostr-object.ts b/packages/nostr/src/nostr-object.ts index 3f837f70..96818d86 100644 --- a/packages/nostr/src/nostr-object.ts +++ b/packages/nostr/src/nostr-object.ts @@ -7,6 +7,7 @@ declare global { getPublicKey: () => Promise signEvent: (event: Unsigned) => Promise + // TODO It's unclear to me if I should use this anywhere. getRelays?: () => Promise<{ [url: string]: { read: boolean; write: boolean } }> diff --git a/packages/nostr/test/browser/index.html b/packages/nostr/test/browser/index.html index 3df15a3d..2a2700c1 100644 --- a/packages/nostr/test/browser/index.html +++ b/packages/nostr/test/browser/index.html @@ -7,6 +7,21 @@ +
+ +
+ + +
@@ -15,6 +30,7 @@ mocha.setup({ ui: "bdd", timeout: "5s", + global: ["nostr"], }) mocha.checkLeaks() diff --git a/packages/nostr/test/browser/server.ts b/packages/nostr/test/browser/server.ts index b4607a1d..a0bfdab6 100644 --- a/packages/nostr/test/browser/server.ts +++ b/packages/nostr/test/browser/server.ts @@ -10,7 +10,7 @@ const port = 33543 const app = express() app.use("/", (req: express.Request, res: express.Response) => { - if (req.path === "/") { + if (req.path === "/" || req.path === "/nostr-object") { const index = fs.readFileSync(path.join(__dirname, "index.html"), { encoding: "utf8", }) diff --git a/packages/nostr/test/setup.ts b/packages/nostr/test/setup.ts index d34324d2..95542319 100644 --- a/packages/nostr/test/setup.ts +++ b/packages/nostr/test/setup.ts @@ -1,11 +1,21 @@ +import "../src/nostr-object" import { Nostr } from "../src/client" import { Timestamp, unixTimestamp } from "../src/common" +import { + aesDecryptBase64, + aesEncryptBase64, + parsePrivateKey, + parsePublicKey, + PublicKey, +} from "../src/crypto" +import { RawEvent } from "../src" +import { signEvent, Unsigned } from "../src/event" export const relayUrl = new URL("ws://localhost:12648") export interface Setup { publisher: Nostr - publisherSecret: string + publisherSecret?: string publisherPubkey: string subscriber: Nostr subscriberSecret: string @@ -25,6 +35,50 @@ export async function setup( ) { try { await restartRelay() + + const publisherPubkey = + "npub1he978sxy7tgc7yfp2zra05v045kfuqnfl3gwr82jd00mzxjj9fjqzw2dg7" + const publisherSecret = + "nsec15fnff4uxlgyu79ua3l7327w0wstrd6x565cx6zze78zgkktmr8vs90j363" + + // Set up the global window.nostr object for the publisher. + if (typeof window !== "undefined") { + if (window.location.pathname === "/nostr-object") { + window.nostr = { + getPublicKey: () => Promise.resolve(parsePublicKey(publisherPubkey)), + signEvent: (event: Unsigned) => + signEvent(event, publisherSecret), + + getRelays: () => Promise.resolve({}), + + nip04: { + encrypt: async (pubkey: PublicKey, plaintext: string) => { + const { data, iv } = await aesEncryptBase64( + parsePrivateKey(publisherSecret), + pubkey, + plaintext + ) + return `${data}?iv=${iv}` + }, + decrypt: async (pubkey: PublicKey, ciphertext: string) => { + const [data, iv] = ciphertext.split("?iv=") + return await aesDecryptBase64( + pubkey, + parsePrivateKey(publisherSecret), + { + data, + iv, + } + ) + }, + }, + } + } else { + // Otherwise, disable the user's nostr extension if they have one. + window.nostr = undefined + } + } + const publisher = new Nostr() const subscriber = new Nostr() @@ -44,9 +98,10 @@ export async function setup( const result = test({ publisher, publisherSecret: - "nsec15fnff4uxlgyu79ua3l7327w0wstrd6x565cx6zze78zgkktmr8vs90j363", - publisherPubkey: - "npub1he978sxy7tgc7yfp2zra05v045kfuqnfl3gwr82jd00mzxjj9fjqzw2dg7", + typeof window === "undefined" || window.nostr === undefined + ? publisherSecret + : undefined, + publisherPubkey, subscriber, subscriberSecret: "nsec1fxvlyqn3rugvxwaz6dr5h8jcfn0fe0lxyp7pl4mgntxfzqr7dmgst7z9ps", diff --git a/packages/nostr/test/test.dm.ts b/packages/nostr/test/test.direct-message.ts similarity index 99% rename from packages/nostr/test/test.dm.ts rename to packages/nostr/test/test.direct-message.ts index 14c69fd5..de1c85ad 100644 --- a/packages/nostr/test/test.dm.ts +++ b/packages/nostr/test/test.direct-message.ts @@ -4,7 +4,7 @@ import { parsePublicKey } from "../src/crypto" import { setup } from "./setup" import { createDirectMessage } from "../src/event/direct-message" -describe("dm", () => { +describe("direct-message", () => { const message = "for your eyes only" // Test that the intended recipient can receive and decrypt the direct message. From d754551ebbf4a753ebbaeb9ed47fc66652a8bdae Mon Sep 17 00:00:00 2001 From: ennmichael Date: Sun, 9 Apr 2023 20:20:32 +0200 Subject: [PATCH 3/4] cleanup --- packages/nostr/src/index.ts | 1 + packages/nostr/src/nostr-object.ts | 1 - packages/nostr/test/setup.ts | 4 ++-- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/nostr/src/index.ts b/packages/nostr/src/index.ts index c74de3f8..2e897582 100644 --- a/packages/nostr/src/index.ts +++ b/packages/nostr/src/index.ts @@ -1,3 +1,4 @@ export * from "./legacy" +import "./nostr-object" // TODO This file should only contain re-exports and only re-export what is needed diff --git a/packages/nostr/src/nostr-object.ts b/packages/nostr/src/nostr-object.ts index 96818d86..3f837f70 100644 --- a/packages/nostr/src/nostr-object.ts +++ b/packages/nostr/src/nostr-object.ts @@ -7,7 +7,6 @@ declare global { getPublicKey: () => Promise signEvent: (event: Unsigned) => Promise - // TODO It's unclear to me if I should use this anywhere. getRelays?: () => Promise<{ [url: string]: { read: boolean; write: boolean } }> diff --git a/packages/nostr/test/setup.ts b/packages/nostr/test/setup.ts index 95542319..25638acf 100644 --- a/packages/nostr/test/setup.ts +++ b/packages/nostr/test/setup.ts @@ -41,9 +41,9 @@ export async function setup( const publisherSecret = "nsec15fnff4uxlgyu79ua3l7327w0wstrd6x565cx6zze78zgkktmr8vs90j363" - // Set up the global window.nostr object for the publisher. if (typeof window !== "undefined") { if (window.location.pathname === "/nostr-object") { + // Mock the global window.nostr object for the publisher. window.nostr = { getPublicKey: () => Promise.resolve(parsePublicKey(publisherPubkey)), signEvent: (event: Unsigned) => @@ -74,7 +74,7 @@ export async function setup( }, } } else { - // Otherwise, disable the user's nostr extension if they have one. + // Disable the user's nostr extension if they have one. window.nostr = undefined } } From 1e5811b117c663b8af08d98770aa27cf13d7daf7 Mon Sep 17 00:00:00 2001 From: ennmichael Date: Mon, 17 Apr 2023 22:07:54 +0200 Subject: [PATCH 4/4] fix build error --- packages/app/src/Pages/Login.tsx | 4 ++-- packages/app/src/System/EventPublisher.ts | 24 +++++------------------ packages/nostr/src/nostr-object.ts | 6 +++--- 3 files changed, 10 insertions(+), 24 deletions(-) diff --git a/packages/app/src/Pages/Login.tsx b/packages/app/src/Pages/Login.tsx index 0586586e..a3d93183 100644 --- a/packages/app/src/Pages/Login.tsx +++ b/packages/app/src/Pages/Login.tsx @@ -145,8 +145,8 @@ export default function LoginPage() { } async function doNip07Login() { - const relays = "getRelays" in window.nostr ? await window.nostr.getRelays() : undefined; - const pubKey = await window.nostr.getPublicKey(); + const relays = "getRelays" in unwrap(window.nostr) ? await unwrap(window.nostr?.getRelays)() : undefined; + const pubKey = await unwrap(window.nostr).getPublicKey(); LoginStore.loginWithPubkey(pubKey, relays); } diff --git a/packages/app/src/System/EventPublisher.ts b/packages/app/src/System/EventPublisher.ts index 4a56b4d0..90f3efff 100644 --- a/packages/app/src/System/EventPublisher.ts +++ b/packages/app/src/System/EventPublisher.ts @@ -17,20 +17,6 @@ import { unwrap } from "Util"; import { EventBuilder } from "./EventBuilder"; import { EventExt } from "./EventExt"; -declare global { - interface Window { - nostr: { - getPublicKey: () => Promise; - signEvent: (event: RawEvent) => Promise; - getRelays: () => Promise>; - nip04: { - encrypt: (pubkey: HexKey, content: string) => Promise; - decrypt: (pubkey: HexKey, content: string) => Promise; - }; - }; - } -} - interface Nip7QueueItem { next: () => Promise; resolve(v: unknown): void; @@ -88,12 +74,12 @@ export class EventPublisher { async #sign(eb: EventBuilder) { if (this.#hasNip07 && !this.#privateKey) { - const nip7PubKey = await barrierNip07(() => window.nostr.getPublicKey()); + const nip7PubKey = await barrierNip07(() => unwrap(window.nostr).getPublicKey()); if (nip7PubKey !== this.#pubKey) { throw new Error("Can't sign event, NIP-07 pubkey does not match"); } const ev = eb.build(); - return await barrierNip07(() => window.nostr.signEvent(ev)); + return await barrierNip07(() => unwrap(window.nostr).signEvent(ev)); } else if (this.#privateKey) { return await eb.buildAndSign(this.#privateKey); } else { @@ -103,11 +89,11 @@ export class EventPublisher { async nip4Encrypt(content: string, key: HexKey) { if (this.#hasNip07 && !this.#privateKey) { - const nip7PubKey = await barrierNip07(() => window.nostr.getPublicKey()); + const nip7PubKey = await barrierNip07(() => unwrap(window.nostr).getPublicKey()); if (nip7PubKey !== this.#pubKey) { throw new Error("Can't encrypt content, NIP-07 pubkey does not match"); } - return await barrierNip07(() => window.nostr.nip04.encrypt(key, content)); + return await barrierNip07(() => unwrap(window.nostr?.nip04?.encrypt)(key, content)); } else if (this.#privateKey) { return await EventExt.encryptData(content, key, this.#privateKey); } else { @@ -117,7 +103,7 @@ export class EventPublisher { async nip4Decrypt(content: string, otherKey: HexKey) { if (this.#hasNip07 && !this.#privateKey) { - return await barrierNip07(() => window.nostr.nip04.decrypt(otherKey, content)); + return await barrierNip07(() => unwrap(window.nostr?.nip04?.decrypt)(otherKey, content)); } else if (this.#privateKey) { return await EventExt.decryptDm(content, this.#privateKey, otherKey); } else { diff --git a/packages/nostr/src/nostr-object.ts b/packages/nostr/src/nostr-object.ts index 3f837f70..da74197d 100644 --- a/packages/nostr/src/nostr-object.ts +++ b/packages/nostr/src/nostr-object.ts @@ -7,9 +7,9 @@ declare global { getPublicKey: () => Promise signEvent: (event: Unsigned) => Promise - getRelays?: () => Promise<{ - [url: string]: { read: boolean; write: boolean } - }> + getRelays?: () => Promise< + Record + > nip04?: { encrypt?: (pubkey: PublicKey, plaintext: string) => Promise