diff --git a/packages/app/src/Pages/Login.tsx b/packages/app/src/Pages/Login.tsx index 35125498..fe166ad9 100644 --- a/packages/app/src/Pages/Login.tsx +++ b/packages/app/src/Pages/Login.tsx @@ -109,8 +109,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 9a6aae17..cff608ab 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; @@ -91,12 +77,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 { @@ -106,11 +92,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 { @@ -120,7 +106,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/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/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..8a3d853f 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,22 @@ 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") + } + // 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/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 new file mode 100644 index 00000000..da74197d --- /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< + Record + > + + nip04?: { + encrypt?: (pubkey: PublicKey, plaintext: string) => Promise + decrypt?: (pubkey: PublicKey, ciphertext: string) => Promise + } + } + } +} 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..25638acf 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" + + 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) => + 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 { + // 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.