diff --git a/ndk/src/events/encryption.test.ts b/ndk/src/events/encryption.test.ts new file mode 100644 index 00000000..297089bf --- /dev/null +++ b/ndk/src/events/encryption.test.ts @@ -0,0 +1,56 @@ +import { NDKEvent } from "."; +import { NDK } from "../ndk"; +import { NDKPrivateKeySigner } from "../signers/private-key"; + +const PRIVATE_KEY_1_FOR_TESTING = '1fbc12b81e0b21f10fb219e88dd76fc80c7aa5369779e44e762fec6f460d6a89'; +const PRIVATE_KEY_2_FOR_TESTING = "d30b946562050e6ced827113da15208730879c46547061b404434edff63236fa"; + + +describe("NDKEvent encryption (Nip44 & Nip59)", ()=>{ + + it("encrypts and decrypts an NDKEvent using Nip44", async () => { + const senderSigner = new NDKPrivateKeySigner(PRIVATE_KEY_1_FOR_TESTING); + const senderUser = await senderSigner.user(); + const recipientSigner = new NDKPrivateKeySigner(PRIVATE_KEY_2_FOR_TESTING); + const recipientUser = await recipientSigner.user(); + + const sendEvent: NDKEvent = new NDKEvent (new NDK(), { + pubkey: senderUser.pubkey, + created_at: Math.floor(Date.now() / 1000), + tags: [], + content: "Test content", + kind: 1, + }); + + const original = sendEvent.content + await sendEvent.encrypt(recipientUser, senderSigner,'nip44'); + const recieveEvent = new NDKEvent(new NDK(), sendEvent.rawEvent()) + await recieveEvent.decrypt(senderUser, recipientSigner,'nip44'); + const decrypted = recieveEvent.content + + expect(decrypted).toBe(original); + }); + + it("gift wraps and unwraps an NDKEvent using Nip59", async () => { + const sendsigner = new NDKPrivateKeySigner(PRIVATE_KEY_1_FOR_TESTING) + const senduser = await sendsigner.user(); + const recievesigner = new NDKPrivateKeySigner(PRIVATE_KEY_2_FOR_TESTING) + const recieveuser = await recievesigner.user(); + + let message = new NDKEvent(new NDK(),{ + kind : 1, + pubkey : senduser.pubkey, + content : "hello world", + created_at : new Date().valueOf(), + tags : [] + }) + + // console.log('MESSAGE EVENT : '+ JSON.stringify(message.rawEvent())) + const wrapped = await message.giftWrap(recieveuser,sendsigner); + // console.log('MESSAGE EVENT WRAPPED : '+ JSON.stringify(wrapped.rawEvent())) + const unwrapped = await wrapped.giftUnwrap(senduser,recievesigner) + // console.log('MESSAGE EVENT UNWRAPPED : '+ JSON.stringify(unwrapped?.rawEvent())) + expect(unwrapped).toBe(message) + }); + +}) diff --git a/ndk/src/events/encryption.ts b/ndk/src/events/encryption.ts new file mode 100644 index 00000000..19f5059a --- /dev/null +++ b/ndk/src/events/encryption.ts @@ -0,0 +1,201 @@ +/** + * Encryption and giftwrapping of events + * Implemnents Nip04, Nip44, Nip59 + */ +import type { NDKSigner } from "../signers"; +import { NDKUser } from "../user"; +import { NDKEvent, NostrEvent } from "./index.js"; +import { NDKPrivateKeySigner } from "../signers/private-key"; +import { VerifiedEvent, getEventHash, verifiedSymbol } from "nostr-tools"; + + +// NIP04 && NIP44 + +export type EncryptionNip = 'nip04' | 'nip44'; +export type EncryptionMethod = 'encrypt' | 'decrypt' + +// some clients may wish to set a default for message encryption... +// TODO how should replies to 'nip04' encrypted messages be handled? +let defaultEncryption : EncryptionNip | undefined = undefined; +export function useEncryption(nip : EncryptionNip){ + defaultEncryption = nip; +} + +export async function encrypt( + this: NDKEvent, + recipient?: NDKUser, + signer?: NDKSigner, + nip : EncryptionNip | undefined = defaultEncryption +): Promise { + let encrypted : string | undefined; + if (!this.ndk) throw new Error("No NDK instance found!"); + if (!signer) { + await this.ndk.assertSigner(); + signer = this.ndk.signer; + } + if(!signer) throw new Error('no NDK signer'); + if (!recipient) { + const pTags = this.getMatchingTags("p"); + + if (pTags.length !== 1) { + throw new Error( + "No recipient could be determined and no explicit recipient was provided" + ); + } + + recipient = this.ndk.getUser({ pubkey: pTags[0][1] }); + } + + // support for encrypting events via legacy `nip04`. adapted from Coracle + if ((!nip || nip == 'nip04') && await isEncryptionEnabled(signer, 'nip04')) { + try{ + encrypted = (await signer?.encrypt(recipient, this.content, 'nip04')) as string; + }catch{} + } + if ((!encrypted || nip == "nip44") && await isEncryptionEnabled(signer, 'nip44')) { + encrypted = (await signer?.encrypt(recipient, this.content, 'nip44')) as string; + } + if(!encrypted) throw new Error('Failed to encrypt event.') + this.content = encrypted + } + +export async function decrypt(this: NDKEvent, sender?: NDKUser, signer?: NDKSigner, nip: EncryptionNip | undefined = defaultEncryption): Promise { + let decrypted : string | undefined; + if (!this.ndk) throw new Error("No NDK instance found!"); + if (!signer) { + await this.ndk.assertSigner(); + signer = this.ndk.signer; + } + if(!signer) throw new Error('no NDK signer'); + if (!sender) { + sender = this.author; + } + // simple check for legacy `nip04` encrypted events. adapted from Coracle + if ((!nip || nip=='nip04') && await isEncryptionEnabled(signer, 'nip04') && this.content.search("?iv=")) { + try{ + decrypted = (await signer?.decrypt(sender, this.content, 'nip04')) as string; + }catch{} + } + if (!decrypted && await isEncryptionEnabled(signer, 'nip44')) { + decrypted = (await signer?.decrypt(sender, this.content, 'nip44')) as string; + } + if(!decrypted) throw new Error('Failed to decrypt event.') + this.content = decrypted +} + +async function isEncryptionEnabled(signer : NDKSigner, nip? : EncryptionNip){ + if(!signer.encryptionEnabled) return false; + if(!nip) return true; + return Boolean(await signer.encryptionEnabled(nip)); +} + + +// NIP 59 - adapted from Coracle + +export type GiftWrapParams = { + encryptionNip?: EncryptionNip + rumorKind?: number; + wrapKind?: 1059 | 1060 + wrapTags?: string[][] +} + +/** + * Instantiate a new (Nip59 gift wrapped) NDKEvent from any NDKevent + * @param this + * @param recipient + * @param signer + * @param params + * @returns + */ +export async function giftWrap(this:NDKEvent, recipient: NDKUser, signer?:NDKSigner, params:GiftWrapParams = {}) : Promise{ + params.encryptionNip = params.encryptionNip || 'nip44'; + if(!signer){ + if(!this.ndk) + throw new Error('no signer available for giftWrap') + signer = this.ndk.signer; + } + if(!signer) + throw new Error('no signer') + if(!signer.encryptionEnabled || !signer.encryptionEnabled(params.encryptionNip)) + throw new Error('signer is not able to giftWrap') + const rumor = getRumorEvent(this, params?.rumorKind) + const seal = await getSealEvent(rumor, recipient, signer, params.encryptionNip); + const wrap = await getWrapEvent(seal, recipient, params); + return new NDKEvent(this.ndk, wrap); +} + +/** + * Instantiate a new (Nip59 un-wrapped rumor) NDKEvent from any gift wrapped NDKevent + * @param this + */ +export async function giftUnwrap(this:NDKEvent, sender?:NDKUser, signer?:NDKSigner, nip:EncryptionNip = 'nip44') : Promise{ + sender = sender || new NDKUser({pubkey:this.pubkey}) + if(!signer){ + if(!this.ndk) + throw new Error('no signer available for giftUnwrap') + signer = this.ndk.signer; + } + if(!signer) + throw new Error('no signer') + try { + + const seal = JSON.parse(await signer.decrypt(sender, this.content, nip)); + if (!seal) throw new Error("Failed to decrypt wrapper") + + const rumor = JSON.parse(await signer.decrypt(sender, seal.content, nip)) + if (!rumor) throw new Error("Failed to decrypt seal") + + if (seal.pubkey === rumor.pubkey) { + return new NDKEvent(this.ndk, rumor as NostrEvent) + } + } catch (e) { + console.log(e) + } + return null + } + + + +function getRumorEvent(event:NDKEvent, kind?:number):NDKEvent{ + let rumor = event.rawEvent(); + rumor.kind = kind || rumor.kind || 1; + rumor.sig = undefined; + rumor.id = getEventHash(rumor as any); + return new NDKEvent(event.ndk, rumor) +} + +async function getSealEvent(rumor : NDKEvent, recipient : NDKUser, signer:NDKSigner, nip:EncryptionNip = 'nip44') : Promise{ + const content = await signer.encrypt(recipient, JSON.stringify(rumor), nip); + let seal : any = { + kind: 13, + created_at: aproximateNow(5), + tags: [], + content , + pubkey : rumor.pubkey + } + seal.id = getEventHash(seal), + seal.sig = await signer.sign(seal); + seal[verifiedSymbol] = true + return seal; +} + +async function getWrapEvent(sealed:VerifiedEvent, recipient:NDKUser, params? : GiftWrapParams) : Promise{ + const signer = NDKPrivateKeySigner.generate(); + const content = await signer.encrypt(recipient, JSON.stringify(sealed), params?.encryptionNip || 'nip44') + const pubkey = (await signer.user()).pubkey + let wrap : any = { + kind : params?.wrapKind || 1059, + created_at: aproximateNow(5), + tags: (params?.wrapTags || []).concat([["p", recipient.pubkey]]), + content, + pubkey, + } + wrap.id = getEventHash(wrap); + wrap.sig = await signer.sign(wrap); + wrap[verifiedSymbol] = true + return wrap; +} + +function aproximateNow(drift = 0){ + return Math.round(Date.now() / 1000 - Math.random() * Math.pow(10, drift)) +} diff --git a/ndk/src/events/index.ts b/ndk/src/events/index.ts index eb2f61ae..c4e7e8de 100644 --- a/ndk/src/events/index.ts +++ b/ndk/src/events/index.ts @@ -10,7 +10,7 @@ import { type NDKUser } from "../user/index.js"; import { type ContentTag, generateContentTags, mergeTags } from "./content-tagger.js"; import { isEphemeral, isParamReplaceable, isReplaceable } from "./kind.js"; import { NDKKind } from "./kinds/index.js"; -import { decrypt, encrypt } from "./nip04.js"; +import { decrypt, encrypt, giftUnwrap, giftWrap } from "./encryption.js"; import { encode } from "./nip19.js"; import { repost } from "./repost.js"; import { fetchReplyEvent, fetchRootEvent, fetchTaggedEvent } from "./fetch-tagged-event.js"; @@ -231,6 +231,8 @@ export class NDKEvent extends EventEmitter { public encode = encode.bind(this); public encrypt = encrypt.bind(this); public decrypt = decrypt.bind(this); + public giftWrap = giftWrap.bind(this); + public giftUnwrap = giftUnwrap.bind(this); /** * Get all tags with the given name diff --git a/ndk/src/events/kinds/index.ts b/ndk/src/events/kinds/index.ts index 9c80eee4..95674280 100644 --- a/ndk/src/events/kinds/index.ts +++ b/ndk/src/events/kinds/index.ts @@ -14,6 +14,12 @@ export enum NDKKind { GroupNote = 11, GroupReply = 12, + // Nip 59 : Gift Wrap + GiftWrap = 1059, + GiftWrapSeal = 13, + // Gift Wrapped Rumors + PrivateDirectMessage = 14, + GenericRepost = 16, ChannelCreation = 40, ChannelMetadata = 41, diff --git a/ndk/src/events/nip04.ts b/ndk/src/events/nip04.ts deleted file mode 100644 index 849f7c97..00000000 --- a/ndk/src/events/nip04.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { NDKSigner } from "../signers"; -import type { NDKUser } from "../user"; -import type { NDKEvent } from "./index.js"; - -export async function encrypt( - this: NDKEvent, - recipient?: NDKUser, - signer?: NDKSigner -): Promise { - if (!this.ndk) throw new Error("No NDK instance found!"); - if (!signer) { - await this.ndk.assertSigner(); - signer = this.ndk.signer; - } - if (!recipient) { - const pTags = this.getMatchingTags("p"); - - if (pTags.length !== 1) { - throw new Error( - "No recipient could be determined and no explicit recipient was provided" - ); - } - - recipient = this.ndk.getUser({ pubkey: pTags[0][1] }); - } - - this.content = (await signer?.encrypt(recipient, this.content)) as string; -} - -export async function decrypt(this: NDKEvent, sender?: NDKUser, signer?: NDKSigner): Promise { - if (!this.ndk) throw new Error("No NDK instance found!"); - if (!signer) { - await this.ndk.assertSigner(); - signer = this.ndk.signer; - } - if (!sender) { - sender = this.author; - } - - this.content = (await signer?.decrypt(sender, this.content)) as string; -} diff --git a/ndk/src/signers/index.ts b/ndk/src/signers/index.ts index 9e2e52aa..fda7971e 100644 --- a/ndk/src/signers/index.ts +++ b/ndk/src/signers/index.ts @@ -1,7 +1,9 @@ +import { EncryptionNip } from "../events/encryption.js"; import type { NostrEvent } from "../events/index.js"; import { NDKRelay } from "../relay/index.js"; import type { NDKUser } from "../user"; + /** * Interface for NDK signers. */ @@ -31,16 +33,29 @@ export interface NDKSigner { */ relays?(): Promise; + /** + * Determine the types of encryption (by nip) that this signer can perform. + * Implementing classes SHOULD return a value even for legacy (only nip04) third party signers. + * @nip Optionally returns an array with single supported nip or empty, to check for truthy or falsy. + * @return A promised list of any (or none) of these strings ['nip04', 'nip44'] + */ + encryptionEnabled?(nip?:EncryptionNip): Promise + /** * Encrypts the given Nostr event for the given recipient. + * Implementing classes SHOULD equate legacy (only nip04) to nip == `nip04` || undefined + * @param recipient - The recipient (pubkey or conversationKey) of the encrypted value. * @param value - The value to be encrypted. - * @param recipient - The recipient of the encrypted value. + * @param nip - which NIP is being implemented ('nip04', 'nip44') */ - encrypt(recipient: NDKUser, value: string): Promise; + encrypt(recipient: NDKUser, value: string, nip?:EncryptionNip): Promise; /** * Decrypts the given value. - * @param value + * Implementing classes SHOULD equate legacy (only nip04) to nip == `nip04` || undefined + * @param sender - The sender (pubkey or conversationKey) of the encrypted value + * @param value - The value to be decrypted + * @param nip - which NIP is being implemented ('nip04', 'nip44', 'nip49') */ - decrypt(sender: NDKUser, value: string): Promise; + decrypt(sender: NDKUser, value: string, nip?:EncryptionNip): Promise; } diff --git a/ndk/src/signers/nip07/index.ts b/ndk/src/signers/nip07/index.ts index 0a6500f6..8d36f359 100644 --- a/ndk/src/signers/nip07/index.ts +++ b/ndk/src/signers/nip07/index.ts @@ -4,9 +4,11 @@ import type { NostrEvent } from "../../events/index.js"; import { NDKUser } from "../../user/index.js"; import type { NDKSigner } from "../index.js"; import { NDKRelay } from "../../relay/index.js"; +import { EncryptionMethod, EncryptionNip } from "../../events/encryption.js"; -type Nip04QueueItem = { - type: "encrypt" | "decrypt"; +type EncryptionQueueItem = { + nip : EncryptionNip; + method: EncryptionMethod; counterpartyHexpubkey: string; value: string; resolve: (value: string) => void; @@ -26,8 +28,8 @@ type Nip07RelayMap = { */ export class NDKNip07Signer implements NDKSigner { private _userPromise: Promise | undefined; - public nip04Queue: Nip04QueueItem[] = []; - private nip04Processing = false; + public encryptionQueue: EncryptionQueueItem[] = []; + private encryptionProcessing = false; private debug: debug.Debugger; private waitTimeout: number; @@ -92,65 +94,69 @@ export class NDKNip07Signer implements NDKSigner { return activeRelays.map((url) => new NDKRelay(url)); } - public async encrypt(recipient: NDKUser, value: string): Promise { + public async encryptionEnabled(nip?:EncryptionNip): Promise{ + let enabled : EncryptionNip[] = [] + if((!nip || nip == 'nip04') && Boolean((window as any).nostr!.nip04)) enabled.push('nip04') + if((!nip || nip == 'nip44') && Boolean((window as any).nostr!.nip44)) enabled.push('nip44') + return enabled; + } + + public async encrypt(recipient: NDKUser, value: string, nip:EncryptionNip = 'nip04'): Promise { + if( !(await this.encryptionEnabled(nip)) ) throw new Error(nip + 'encryption is not available from your browser extension') await this.waitForExtension(); const recipientHexPubKey = recipient.pubkey; - return this.queueNip04("encrypt", recipientHexPubKey, value); + return this.queueEncryption(nip, "encrypt", recipientHexPubKey, value); } - public async decrypt(sender: NDKUser, value: string): Promise { + public async decrypt(sender: NDKUser, value: string, nip:EncryptionNip = 'nip04'): Promise { + if( !(await this.encryptionEnabled(nip)) ) throw new Error(nip + 'encryption is not available from your browser extension') await this.waitForExtension(); const senderHexPubKey = sender.pubkey; - return this.queueNip04("decrypt", senderHexPubKey, value); + return this.queueEncryption(nip, "decrypt", senderHexPubKey, value); } - private async queueNip04( - type: "encrypt" | "decrypt", + private async queueEncryption( + nip : EncryptionNip, + method: EncryptionMethod, counterpartyHexpubkey: string, value: string ): Promise { return new Promise((resolve, reject) => { - this.nip04Queue.push({ - type, + this.encryptionQueue.push({ + nip, + method, counterpartyHexpubkey, value, resolve, reject, }); - if (!this.nip04Processing) { - this.processNip04Queue(); + if (!this.encryptionProcessing) { + this.processEncryptionQueue(); } }); } - private async processNip04Queue(item?: Nip04QueueItem, retries = 0): Promise { - if (!item && this.nip04Queue.length === 0) { - this.nip04Processing = false; + private async processEncryptionQueue(item?: EncryptionQueueItem, retries = 0): Promise { + if (!item && this.encryptionQueue.length === 0) { + this.encryptionProcessing = false; return; } - this.nip04Processing = true; - const { type, counterpartyHexpubkey, value, resolve, reject } = - item || this.nip04Queue.shift()!; + this.encryptionProcessing = true; + const {nip, method, counterpartyHexpubkey, value, resolve, reject } = + item || this.encryptionQueue.shift()!; this.debug("Processing encryption queue item", { - type, + method, counterpartyHexpubkey, value, }); try { - let result; - - if (type === "encrypt") { - result = await window.nostr!.nip04!.encrypt(counterpartyHexpubkey, value); - } else { - result = await window.nostr!.nip04!.decrypt(counterpartyHexpubkey, value); - } - + let result = await window.nostr![nip]![method](counterpartyHexpubkey, value) resolve(result); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { @@ -158,13 +164,13 @@ export class NDKNip07Signer implements NDKSigner { if (error.message && error.message.includes("call already executing")) { if (retries < 5) { this.debug("Retrying encryption queue item", { - type, + method, counterpartyHexpubkey, value, retries, }); setTimeout(() => { - this.processNip04Queue(item, retries + 1); + this.processEncryptionQueue(item, retries + 1); }, 50 * retries); return; @@ -173,7 +179,7 @@ export class NDKNip07Signer implements NDKSigner { reject(error); } - this.processNip04Queue(); + this.processEncryptionQueue(); } private waitForExtension(): Promise { @@ -213,6 +219,10 @@ declare global { encrypt(recipientHexPubKey: string, value: string): Promise; decrypt(senderHexPubKey: string, value: string): Promise; }; + nip44?: { + encrypt(recipientHexPubKey: string, value: string): Promise; + decrypt(senderHexPubKey: string, value: string): Promise; + }; }; } } diff --git a/ndk/src/signers/private-key/index.test.ts b/ndk/src/signers/private-key/index.test.ts index a9f0822f..d4778212 100644 --- a/ndk/src/signers/private-key/index.test.ts +++ b/ndk/src/signers/private-key/index.test.ts @@ -1,5 +1,5 @@ import { generateSecretKey } from "nostr-tools"; -import type { NostrEvent } from "../../index.js"; +import NDK, { NDKEvent, type NostrEvent } from "../../index.js"; import { NDKPrivateKeySigner } from "./index"; import { bytesToHex, hexToBytes } from "@noble/hashes/utils"; import { nip19 } from "nostr-tools"; diff --git a/ndk/src/signers/private-key/index.ts b/ndk/src/signers/private-key/index.ts index 61428705..d5b2e559 100644 --- a/ndk/src/signers/private-key/index.ts +++ b/ndk/src/signers/private-key/index.ts @@ -1,11 +1,13 @@ import type { UnsignedEvent } from "nostr-tools"; -import { generateSecretKey, getPublicKey, finalizeEvent, nip04 } from "nostr-tools"; +import { generateSecretKey, getPublicKey, finalizeEvent, nip04, nip44 } from "nostr-tools"; import type { NostrEvent } from "../../events/index.js"; import { NDKUser } from "../../user"; import type { NDKSigner } from "../index.js"; import { bytesToHex, hexToBytes } from "@noble/hashes/utils"; import { nip19 } from "nostr-tools"; +import { EncryptionNip } from "../../events/encryption.js"; + export class NDKPrivateKeySigner implements NDKSigner { private _user: NDKUser | undefined; @@ -37,7 +39,6 @@ export class NDKPrivateKeySigner implements NDKSigner { } } } - get privateKey(): string | undefined { if (!this._privateKey) return undefined; return bytesToHex(this._privateKey); @@ -68,21 +69,38 @@ export class NDKPrivateKeySigner implements NDKSigner { return finalizeEvent(event as UnsignedEvent, this._privateKey).sig; } - public async encrypt(recipient: NDKUser, value: string): Promise { - if (!this._privateKey) { + public async encryptionEnabled(nip?:EncryptionNip): Promise{ + let enabled : EncryptionNip[] = [] + if((!nip || nip == 'nip04')) enabled.push('nip04') + if((!nip || nip == 'nip44')) enabled.push('nip44') + return enabled; + } + + public async encrypt(recipient: NDKUser, value: string, nip?: EncryptionNip): Promise { + if (!this._privateKey || !this.privateKey) { throw Error("Attempted to encrypt without a private key"); } const recipientHexPubKey = recipient.pubkey; + if(nip == 'nip44'){ + // TODO Deriving shared secret is an expensive computation, should be cached. + let conversationKey = nip44.v2.utils.getConversationKey(this.privateKey, recipientHexPubKey); + return await nip44.v2.encrypt(value, conversationKey); + } return await nip04.encrypt(this._privateKey, recipientHexPubKey, value); } - public async decrypt(sender: NDKUser, value: string): Promise { - if (!this._privateKey) { + public async decrypt(sender: NDKUser, value: string, nip?: EncryptionNip): Promise { + if (!this._privateKey || !this.privateKey) { throw Error("Attempted to decrypt without a private key"); } const senderHexPubKey = sender.pubkey; + if(nip == 'nip44'){ + // TODO Deriving shared secret is an expensive computation, should be cached. + let conversationKey = nip44.v2.utils.getConversationKey(this.privateKey, senderHexPubKey); + return await nip44.v2.decrypt(value, conversationKey); + } return await nip04.decrypt(this._privateKey, senderHexPubKey, value); } }