diff --git a/packages/app/src/chat/nip24.ts b/packages/app/src/chat/nip24.ts index d65ce9e7..ac25a359 100644 --- a/packages/app/src/chat/nip24.ts +++ b/packages/app/src/chat/nip24.ts @@ -1,12 +1,13 @@ import { ExternalStore, dedupe } from "@snort/shared"; import { EventKind, - SystemInterface, NostrPrefix, encodeTLVEntries, TLVEntryType, TLVEntry, decodeTLV, + PowWorker, + NostrEvent, } from "@snort/system"; import { GiftWrapCache } from "Cache/GiftWrapCache"; import { UnwrappedGift } from "Db"; @@ -103,16 +104,23 @@ export class Nip24ChatSystem extends ExternalStore> implements ChatS } return eb; }); - const messages = []; + const messages: Array> = []; + const powTarget = 4 * 4; // 4-char zero for (const pt of participants) { - const recvSealedN = await pub.giftWrap(await pub.sealRumor(gossip, pt.id), pt.id); + const recvSealedN = pub.giftWrap( + await pub.sealRumor(gossip, pt.id), + pt.id, + powTarget, + new PowWorker("/pow.js") + ); messages.push(recvSealedN); } - const sendSealed = await pub.giftWrap(await pub.sealRumor(gossip, pub.pubKey), pub.pubKey); - return [...messages, sendSealed]; + messages.push( + pub.giftWrap(await pub.sealRumor(gossip, pub.pubKey), pub.pubKey, powTarget, new PowWorker("/pow.js")) + ); + return await Promise.all(messages); }, - sendMessage: (ev, system: SystemInterface) => { - console.debug(ev); + sendMessage: (ev, system) => { ev.forEach(a => system.BroadcastEvent(a)); }, } as Chat; diff --git a/packages/app/webpack.config.js b/packages/app/webpack.config.js index 42b9f793..dd94e14a 100644 --- a/packages/app/webpack.config.js +++ b/packages/app/webpack.config.js @@ -17,6 +17,10 @@ const config = { import: "./src/service-worker.ts", filename: "service-worker.js", }, + pow: { + import: require.resolve("@snort/system/dist/pow-worker.js"), + filename: "pow.js", + }, }, target: "browserslist", mode: isProduction ? "production" : "development", @@ -24,12 +28,7 @@ const config = { output: { publicPath: "/", path: path.resolve(__dirname, "build"), - filename: ({ runtime }) => { - if (runtime === "sw") { - return "[name].js"; - } - return isProduction ? "[name].[chunkhash].js" : "[name].js"; - }, + filename: isProduction ? "[name].[chunkhash].js" : "[name].js", clean: isProduction, }, devServer: { @@ -50,7 +49,7 @@ const config = { new HtmlWebpackPlugin({ template: "public/index.html", favicon: "public/favicon.ico", - excludeChunks: ["sw"], + excludeChunks: ["sw", "pow"], }), new ESLintPlugin({ extensions: ["js", "mjs", "jsx", "ts", "tsx"], diff --git a/packages/system/src/event-builder.ts b/packages/system/src/event-builder.ts index c9beb2b3..cfe88bb6 100644 --- a/packages/system/src/event-builder.ts +++ b/packages/system/src/event-builder.ts @@ -1,4 +1,4 @@ -import { EventKind, HexKey, NostrPrefix, NostrEvent, EventSigner } from "."; +import { EventKind, HexKey, NostrPrefix, NostrEvent, EventSigner, PowMiner } from "."; import { HashtagRegex, MentionNostrEntityRegex } from "./const"; import { getPublicKey, unixNow } from "@snort/shared"; import { EventExt } from "./event-ext"; @@ -10,6 +10,8 @@ export class EventBuilder { #createdAt?: number; #pubkey?: string; #tags: Array> = []; + #pow?: number; + #powMiner?: PowMiner; kind(k: EventKind) { this.#kind = k; @@ -38,6 +40,17 @@ export class EventBuilder { return this; } + pow(target: number, miner?: PowMiner) { + this.#pow = target; + this.#powMiner = miner ?? { + minePow: (ev, target) => { + EventExt.minePow(ev, target); + return Promise.resolve(ev); + }, + }; + return this; + } + /** * Extract mentions */ @@ -74,14 +87,21 @@ export class EventBuilder { async buildAndSign(pk: HexKey | EventSigner) { if (typeof pk === "string") { const ev = this.pubKey(getPublicKey(pk)).build(); - EventExt.sign(ev, pk); - return ev; + return EventExt.sign(await this.#mine(ev), pk); } else { const ev = this.pubKey(await pk.getPubKey()).build(); - return await pk.sign(ev); + return await pk.sign(await this.#mine(ev)); } } + async #mine(ev: NostrEvent) { + if (this.#pow && this.#powMiner) { + const ret = await this.#powMiner.minePow(ev, this.#pow); + return ret; + } + return ev; + } + #validate() { if (this.#kind === undefined) { throw new Error("Kind must be set"); diff --git a/packages/system/src/event-ext.ts b/packages/system/src/event-ext.ts index c8d6bacc..9a048387 100644 --- a/packages/system/src/event-ext.ts +++ b/packages/system/src/event-ext.ts @@ -3,7 +3,7 @@ import * as utils from "@noble/curves/abstract/utils"; import { getPublicKey, sha256, unixNow } from "@snort/shared"; import { EventKind, HexKey, NostrEvent, NotSignedNostrEvent } from "."; -import { Nip4WebCryptoEncryptor } from "./impl/nip4"; +import { minePow } from "./pow-util"; export interface Tag { key: string; @@ -44,6 +44,7 @@ export abstract class EventExt { if (!secp.schnorr.verify(e.sig, e.id, e.pubkey)) { throw new Error("Signing failed"); } + return e; } /** @@ -58,13 +59,14 @@ export abstract class EventExt { static createId(e: NostrEvent | NotSignedNostrEvent) { const payload = [0, e.pubkey, e.created_at, e.kind, e.tags, e.content]; + return sha256(JSON.stringify(payload)); + } - const hash = sha256(JSON.stringify(payload)); - if (e.id !== "" && hash !== e.id) { - console.debug(payload); - throw new Error("ID doesnt match!"); - } - return hash; + /** + * Mine POW for an event (NIP-13) + */ + static minePow(e: NostrEvent, target: number) { + return minePow(e, target); } /** diff --git a/packages/system/src/event-publisher.ts b/packages/system/src/event-publisher.ts index 0a652e10..af2c605f 100644 --- a/packages/system/src/event-publisher.ts +++ b/packages/system/src/event-publisher.ts @@ -10,6 +10,7 @@ import { Lists, NostrEvent, NotSignedNostrEvent, + PowMiner, PrivateKeySigner, RelaySettings, TaggedNostrEvent, @@ -285,7 +286,7 @@ export class EventPublisher { /** * NIP-59 Gift Wrap event with ephemeral key */ - async giftWrap(inner: NostrEvent, explicitP?: string) { + async giftWrap(inner: NostrEvent, explicitP?: string, powTarget?: number, powMiner?: PowMiner) { const secret = utils.bytesToHex(secp.secp256k1.utils.randomPrivateKey()); const signer = new PrivateKeySigner(secret); @@ -296,6 +297,9 @@ export class EventPublisher { eb.pubKey(signer.getPubKey()); eb.kind(EventKind.GiftWrap); eb.tag(["p", pTag]); + if (powTarget) { + eb.pow(powTarget, powMiner); + } eb.content(await signer.nip44Encrypt(JSON.stringify(inner), pTag)); return await eb.buildAndSign(secret); diff --git a/packages/system/src/impl/nip4.ts b/packages/system/src/impl/nip4.ts index c775dca5..146e8342 100644 --- a/packages/system/src/impl/nip4.ts +++ b/packages/system/src/impl/nip4.ts @@ -23,10 +23,10 @@ export class Nip4WebCryptoEncryptor implements MessageEncryptor { return { ciphertext: new Uint8Array(result), nonce: iv, - v: MessageEncryptorVersion.Nip4 + v: MessageEncryptorVersion.Nip4, } as MessageEncryptorPayload; } - + async decryptData(payload: MessageEncryptorPayload, sharedSecet: Uint8Array) { const key = await this.#importKey(sharedSecet); const result = await window.crypto.subtle.decrypt( @@ -35,7 +35,7 @@ export class Nip4WebCryptoEncryptor implements MessageEncryptor { iv: payload.nonce, }, key, - payload.ciphertext + payload.ciphertext, ); return new TextDecoder().decode(result); } diff --git a/packages/system/src/index.ts b/packages/system/src/index.ts index e437d565..86763ea3 100644 --- a/packages/system/src/index.ts +++ b/packages/system/src/index.ts @@ -21,6 +21,7 @@ export * from "./profile-cache"; export * from "./zaps"; export * from "./signer"; export * from "./text"; +export * from "./pow"; export * from "./impl/nip4"; export * from "./impl/nip44"; @@ -60,9 +61,9 @@ export const enum MessageEncryptorVersion { } export interface MessageEncryptorPayload { - ciphertext: Uint8Array, - nonce: Uint8Array, - v: MessageEncryptorVersion + ciphertext: Uint8Array; + nonce: Uint8Array; + v: MessageEncryptorVersion; } export interface MessageEncryptor { diff --git a/packages/system/src/nostr-link.ts b/packages/system/src/nostr-link.ts index c89aa231..54e9ec22 100644 --- a/packages/system/src/nostr-link.ts +++ b/packages/system/src/nostr-link.ts @@ -15,16 +15,17 @@ export function createNostrLink(prefix: NostrPrefix, id: string, relays?: string type: prefix, id, relays, - kind, author, + kind, + author, encode: () => { - if(prefix === NostrPrefix.Note || prefix === NostrPrefix.PublicKey) { + if (prefix === NostrPrefix.Note || prefix === NostrPrefix.PublicKey) { return hexToBech32(prefix, id); - } - if(prefix === NostrPrefix.Address || prefix === NostrPrefix.Event || prefix === NostrPrefix.Profile) { + } + if (prefix === NostrPrefix.Address || prefix === NostrPrefix.Event || prefix === NostrPrefix.Profile) { return encodeTLV(prefix, id, relays, kind, author); } return ""; - } + }, } as NostrLink; } diff --git a/packages/system/src/nostr.ts b/packages/system/src/nostr.ts index 7ea45b80..50fd1934 100644 --- a/packages/system/src/nostr.ts +++ b/packages/system/src/nostr.ts @@ -86,4 +86,4 @@ export interface FullRelaySettings { settings: RelaySettings; } -export type NotSignedNostrEvent = Omit; \ No newline at end of file +export type NotSignedNostrEvent = Omit; diff --git a/packages/system/src/pow-util.ts b/packages/system/src/pow-util.ts new file mode 100644 index 00000000..952e60af --- /dev/null +++ b/packages/system/src/pow-util.ts @@ -0,0 +1,57 @@ +import { sha256 } from "@noble/hashes/sha256"; +import { bytesToHex } from "@noble/hashes/utils"; + +export interface NostrPowEvent { + id: string; + pubkey: string; + created_at: number; + kind: number; + tags: Array>; + content: string; + sig: string; +} + +export function minePow(e: NostrPowEvent, target: number) { + let ctr = 0; + + let nonceTagIdx = e.tags.findIndex(a => a[0] === "nonce"); + if (nonceTagIdx === -1) { + nonceTagIdx = e.tags.length; + e.tags.push(["nonce", ctr.toString(), target.toString()]); + } + do { + //roll ctr and compute id + const now = Math.floor(new Date().getTime() / 1000); + // reset ctr if timestamp changed, this is not really needed but makes the ctr value smaller + if (now !== e.created_at) { + ctr = 0; + e.created_at = now; + } + e.tags[nonceTagIdx][1] = (++ctr).toString(); + + e.id = createId(e); + } while (countLeadingZeroes(e.id) < target); + + return e; +} + +function createId(e: NostrPowEvent) { + const payload = [0, e.pubkey, e.created_at, e.kind, e.tags, e.content]; + return bytesToHex(sha256(JSON.stringify(payload))); +} + +function countLeadingZeroes(hex: string) { + let count = 0; + + for (let i = 0; i < hex.length; i++) { + const nibble = parseInt(hex[i], 16); + if (nibble === 0) { + count += 4; + } else { + count += Math.clz32(nibble) - 28; + break; + } + } + + return count; +} diff --git a/packages/system/src/pow-worker.ts b/packages/system/src/pow-worker.ts new file mode 100644 index 00000000..195e268f --- /dev/null +++ b/packages/system/src/pow-worker.ts @@ -0,0 +1,21 @@ +/// + +import { minePow, NostrPowEvent } from "./pow-util"; + +export interface PowWorkerMessage { + id: string; + cmd: "req" | "rsp"; + event: NostrPowEvent; + target: number; +} + +globalThis.onmessage = ev => { + const data = ev.data as PowWorkerMessage; + if (data.cmd === "req") { + queueMicrotask(() => { + minePow(data.event, data.target); + data.cmd = "rsp"; + globalThis.postMessage(data); + }); + } +}; diff --git a/packages/system/src/pow.ts b/packages/system/src/pow.ts new file mode 100644 index 00000000..543ed3ce --- /dev/null +++ b/packages/system/src/pow.ts @@ -0,0 +1,56 @@ +import { v4 as uuid } from "uuid"; +import { NostrEvent } from "./nostr"; +import { PowWorkerMessage } from "./pow-worker"; + +export interface PowMiner { + minePow(ev: NostrEvent, target: number): Promise; +} + +interface PowQueue { + resolve: (ev: NostrEvent) => void; + reject: () => void; + timeout: ReturnType; +} + +export class PowWorker implements PowMiner { + #worker: Worker; + #queue: Map = new Map(); + + constructor(script: string) { + this.#worker = new Worker(script, { + name: "POW", + }); + this.#worker.onerror = ev => { + console.error(ev); + }; + this.#worker.onmessage = ev => { + const data = ev.data as PowWorkerMessage; + const job = this.#queue.get(data.id); + if (job) { + clearTimeout(job.timeout); + this.#queue.delete(data.id); + job.resolve(data.event); + } + }; + } + + minePow(ev: NostrEvent, target: number) { + return new Promise((resolve, reject) => { + const req = { + id: uuid(), + cmd: "req", + event: ev, + target, + } as PowWorkerMessage; + this.#queue.set(req.id, { + resolve: ex => resolve(ex), + reject, + timeout: setTimeout(() => { + this.#queue.delete(req.id); + reject(); + }, 600_000), + }); + this.#worker.postMessage(req); + }); + } +} diff --git a/packages/system/src/signer.ts b/packages/system/src/signer.ts index 19651828..d2d25c81 100644 --- a/packages/system/src/signer.ts +++ b/packages/system/src/signer.ts @@ -59,7 +59,7 @@ export class PrivateKeySigner implements EventSigner { nonce: base64.decode(iv), v: MessageEncryptorVersion.Nip4, }, - secret + secret, ); } diff --git a/packages/system/src/utils.ts b/packages/system/src/utils.ts index 3553ece2..a38fd70a 100644 --- a/packages/system/src/utils.ts +++ b/packages/system/src/utils.ts @@ -49,4 +49,4 @@ export function splitByUrl(str: string) { /((?:http|ftp|https|nostr|web\+nostr|magnet):\/?\/?(?:[\w+?.\w+])+(?:[a-zA-Z0-9~!@#$%^&*()_\-=+\\/?.:;',]*)?(?:[-A-Za-z0-9+&@#/%=~()_|]))/i; return str.split(urlRegex); -} +} \ No newline at end of file