From 7bdddc4138dbe1c1f2eb4a83ae44e08e8976aa82 Mon Sep 17 00:00:00 2001 From: im-adithya Date: Fri, 29 Nov 2024 20:19:24 +0530 Subject: [PATCH] feat: add nip 44 and versioning support --- src/NWCClient.ts | 104 ++++++++++++++++++++++++++-- src/nip44/index.ts | 167 +++++++++++++++++++++++++++++++++++++++++++++ src/nip44/utils.ts | 130 +++++++++++++++++++++++++++++++++++ 3 files changed, 395 insertions(+), 6 deletions(-) create mode 100644 src/nip44/index.ts create mode 100644 src/nip44/utils.ts diff --git a/src/NWCClient.ts b/src/NWCClient.ts index ec378d4..dc69d93 100644 --- a/src/NWCClient.ts +++ b/src/NWCClient.ts @@ -11,6 +11,8 @@ import { finishEvent, Sub, } from "nostr-tools"; +import { hexToBytes } from "@noble/hashes/utils"; +import * as nip44 from "./nip44"; import { NWCAuthorizationUrlOptions } from "./types"; type WithDTag = { @@ -194,6 +196,7 @@ export class Nip47ResponseDecodingError extends Nip47Error {} export class Nip47ResponseValidationError extends Nip47Error {} export class Nip47UnexpectedResponseError extends Nip47Error {} export class Nip47NetworkError extends Nip47Error {} +export class Nip47UnsupportedVersionError extends Nip47Error {} export const NWCs: Record = { alby: { @@ -220,6 +223,9 @@ export class NWCClient { lud16: string | undefined; walletPubkey: string; options: NWCOptions; + version: string | undefined; + + static SUPPORTED_VERSIONS = ["0.0", "1.0"]; static parseWalletConnectUrl(walletConnectUrl: string): NWCOptions { // makes it possible to parse with URL in the different environments (browser/node/...) @@ -316,6 +322,13 @@ export class NWCClient { return getPublicKey(this.secret); } + get supportedVersion(): string { + if (!this.version) { + throw new Error("Missing version"); + } + return this.version; + } + getPublicKey(): Promise { return Promise.resolve(this.publicKey); } @@ -340,7 +353,13 @@ export class NWCClient { if (!this.secret) { throw new Error("Missing secret"); } - const encrypted = await nip04.encrypt(this.secret, pubkey, content); + let encrypted; + if (this.supportedVersion === "0.0") { + encrypted = await nip04.encrypt(this.secret, pubkey, content); + } else { + const key = nip44.getConversationKey(hexToBytes(this.secret), pubkey); + encrypted = nip44.encrypt(content, key); + } return encrypted; } @@ -348,7 +367,13 @@ export class NWCClient { if (!this.secret) { throw new Error("Missing secret"); } - const decrypted = await nip04.decrypt(this.secret, pubkey, content); + let decrypted; + if (this.supportedVersion === "0.0") { + decrypted = await nip04.decrypt(this.secret, pubkey, content); + } else { + const key = nip44.getConversationKey(hexToBytes(this.secret), pubkey); + decrypted = nip44.decrypt(content, key); + } return decrypted; } @@ -455,6 +480,7 @@ export class NWCClient { } async getWalletServiceInfo(): Promise<{ + versions: string[]; capabilities: Nip47Capability[]; notifications: Nip47NotificationType[]; }> { @@ -480,7 +506,9 @@ export class NWCClient { const notificationsTag = events[0].tags.find( (t) => t[0] === "notifications", ); + const versionsTag = events[0].tags.find((t) => t[0] === "v"); return { + versions: versionsTag ? versionsTag[1]?.split(" ") : ["0.0"], // delimiter is " " per spec, but Alby NWC originally returned "," capabilities: content.split(/[ |,]/g) as Nip47Method[], notifications: (notificationsTag?.[1]?.split(" ") || @@ -687,14 +715,14 @@ export class NWCClient { let subscribed = true; let endPromise: (() => void) | undefined; let onRelayDisconnect: (() => void) | undefined; - let sub: Sub<23196> | undefined; + let sub: Sub | undefined; (async () => { while (subscribed) { try { await this._checkConnected(); sub = this.relay.sub([ { - kinds: [23196], + kinds: [...(this.supportedVersion ? [23196] : [23197])], authors: [this.walletPubkey], "#p": [this.publicKey], }, @@ -765,6 +793,7 @@ export class NWCClient { resultValidator: (result: T) => boolean, ): Promise { await this._checkConnected(); + await this._checkCompatibility(); return new Promise((resolve, reject) => { (async () => { const command = { @@ -778,7 +807,10 @@ export class NWCClient { const unsignedEvent: UnsignedEvent = { kind: 23194, created_at: Math.floor(Date.now() / 1000), - tags: [["p", this.walletPubkey]], + tags: [ + ["p", this.walletPubkey], + ["v", this.supportedVersion], + ], content: encryptedCommand, pubkey: this.publicKey, }; @@ -895,6 +927,7 @@ export class NWCClient { resultValidator: (result: T) => boolean, ): Promise<(T & { dTag: string })[]> { await this._checkConnected(); + await this._checkCompatibility(); const results: (T & { dTag: string })[] = []; return new Promise<(T & { dTag: string })[]>((resolve, reject) => { (async () => { @@ -909,7 +942,10 @@ export class NWCClient { const unsignedEvent: UnsignedEvent = { kind: 23194, created_at: Math.floor(Date.now() / 1000), - tags: [["p", this.walletPubkey]], + tags: [ + ["p", this.walletPubkey], + ["v", this.supportedVersion], + ], content: encryptedCommand, pubkey: this.publicKey, }; @@ -1034,6 +1070,7 @@ export class NWCClient { })(); }); } + private async _checkConnected() { if (!this.secret) { throw new Error("Missing secret key"); @@ -1048,4 +1085,59 @@ export class NWCClient { ); } } + + private async _checkCompatibility() { + if (!this.version) { + const walletServiceInfo = await this.getWalletServiceInfo(); + const compatibleVersion = this.selectHighestCompatibleVersion( + walletServiceInfo.versions, + ); + if (!compatibleVersion) { + throw new Nip47UnsupportedVersionError( + `no compatible version found between wallet and client`, + "UNSUPPORTED_VERSION", + ); + } + this.version = compatibleVersion; + } + } + + private selectHighestCompatibleVersion( + walletVersions: string[], + ): string | null { + const parseVersions = (versions: string[]) => + versions.map((v) => v.split(".").map(Number)); + + const walletParsed = parseVersions(walletVersions); + const clientParsed = parseVersions(NWCClient.SUPPORTED_VERSIONS); + + const walletMajors: number[] = walletParsed + .map(([major]) => major) + .filter((value, index, self) => self.indexOf(value) === index); + + const clientMajors: number[] = clientParsed + .map(([major]) => major) + .filter((value, index, self) => self.indexOf(value) === index); + + const commonMajors = walletMajors + .filter((major) => clientMajors.includes(major)) + .sort((a, b) => b - a); + + for (const major of commonMajors) { + const walletMinors = walletParsed + .filter(([m]) => m === major) + .map(([, minor]) => minor); + const clientMinors = clientParsed + .filter(([m]) => m === major) + .map(([, minor]) => minor); + + const highestMinor = Math.min( + Math.max(...walletMinors), + Math.max(...clientMinors), + ); + + return `${major}.${highestMinor}`; + } + return null; + } } diff --git a/src/nip44/index.ts b/src/nip44/index.ts new file mode 100644 index 0000000..ce114ef --- /dev/null +++ b/src/nip44/index.ts @@ -0,0 +1,167 @@ +import { chacha20 } from "@noble/ciphers/chacha"; +import { equalBytes } from "@noble/ciphers/utils"; +import { secp256k1 } from "@noble/curves/secp256k1"; +import { + extract as hkdf_extract, + expand as hkdf_expand, +} from "@noble/hashes/hkdf"; +import { hmac } from "@noble/hashes/hmac"; +import { sha256 } from "@noble/hashes/sha256"; +import { concatBytes, randomBytes } from "@noble/hashes/utils"; +import { base64 } from "@scure/base"; + +import { utf8Decoder, utf8Encoder } from "./utils"; + +const minPlaintextSize = 0x0001; // 1b msg => padded to 32b +const maxPlaintextSize = 0xffff; // 65535 (64kb-1) => padded to 64kb + +export function getConversationKey( + privkeyA: Uint8Array, + pubkeyB: string, +): Uint8Array { + const sharedX = secp256k1 + .getSharedSecret(privkeyA, "02" + pubkeyB) + .subarray(1, 33); + return hkdf_extract(sha256, sharedX, "nip44-v2"); +} + +function getMessageKeys( + conversationKey: Uint8Array, + nonce: Uint8Array, +): { chacha_key: Uint8Array; chacha_nonce: Uint8Array; hmac_key: Uint8Array } { + const keys = hkdf_expand(sha256, conversationKey, nonce, 76); + return { + chacha_key: keys.subarray(0, 32), + chacha_nonce: keys.subarray(32, 44), + hmac_key: keys.subarray(44, 76), + }; +} + +function calcPaddedLen(len: number): number { + if (!Number.isSafeInteger(len) || len < 1) + throw new Error("expected positive integer"); + if (len <= 32) return 32; + const nextPower = 1 << (Math.floor(Math.log2(len - 1)) + 1); + const chunk = nextPower <= 256 ? 32 : nextPower / 8; + return chunk * (Math.floor((len - 1) / chunk) + 1); +} + +function writeU16BE(num: number): Uint8Array { + if ( + !Number.isSafeInteger(num) || + num < minPlaintextSize || + num > maxPlaintextSize + ) + throw new Error( + "invalid plaintext size: must be between 1 and 65535 bytes", + ); + const arr = new Uint8Array(2); + new DataView(arr.buffer).setUint16(0, num, false); + return arr; +} + +function pad(plaintext: string): Uint8Array { + const unpadded = utf8Encoder.encode(plaintext); + const unpaddedLen = unpadded.length; + const prefix = writeU16BE(unpaddedLen); + const suffix = new Uint8Array(calcPaddedLen(unpaddedLen) - unpaddedLen); + return concatBytes(prefix, unpadded, suffix); +} + +function unpad(padded: Uint8Array): string { + const unpaddedLen = new DataView(padded.buffer).getUint16(0); + const unpadded = padded.subarray(2, 2 + unpaddedLen); + if ( + unpaddedLen < minPlaintextSize || + unpaddedLen > maxPlaintextSize || + unpadded.length !== unpaddedLen || + padded.length !== 2 + calcPaddedLen(unpaddedLen) + ) + throw new Error("invalid padding"); + return utf8Decoder.decode(unpadded); +} + +function hmacAad( + key: Uint8Array, + message: Uint8Array, + aad: Uint8Array, +): Uint8Array { + if (aad.length !== 32) + throw new Error("AAD associated data must be 32 bytes"); + const combined = concatBytes(aad, message); + return hmac(sha256, key, combined); +} + +// metadata: always 65b (version: 1b, nonce: 32b, max: 32b) +// plaintext: 1b to 0xffff +// padded plaintext: 32b to 0xffff +// ciphertext: 32b+2 to 0xffff+2 +// raw payload: 99 (65+32+2) to 65603 (65+0xffff+2) +// compressed payload (base64): 132b to 87472b +function decodePayload(payload: string): { + nonce: Uint8Array; + ciphertext: Uint8Array; + mac: Uint8Array; +} { + if (typeof payload !== "string") + throw new Error("payload must be a valid string"); + const plen = payload.length; + if (plen < 132 || plen > 87472) + throw new Error("invalid payload length: " + plen); + if (payload[0] === "#") throw new Error("unknown encryption version"); + let data: Uint8Array; + try { + data = base64.decode(payload); + } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + throw new Error("invalid base64: " + (error as any).message); + } + const dlen = data.length; + if (dlen < 99 || dlen > 65603) + throw new Error("invalid data length: " + dlen); + const vers = data[0]; + if (vers !== 2) throw new Error("unknown encryption version " + vers); + return { + nonce: data.subarray(1, 33), + ciphertext: data.subarray(33, -32), + mac: data.subarray(-32), + }; +} + +export function encrypt( + plaintext: string, + conversationKey: Uint8Array, + nonce: Uint8Array = randomBytes(32), +): string { + const { chacha_key, chacha_nonce, hmac_key } = getMessageKeys( + conversationKey, + nonce, + ); + const padded = pad(plaintext); + const ciphertext = chacha20(chacha_key, chacha_nonce, padded); + const mac = hmacAad(hmac_key, ciphertext, nonce); + return base64.encode( + concatBytes(new Uint8Array([2]), nonce, ciphertext, mac), + ); +} + +export function decrypt(payload: string, conversationKey: Uint8Array): string { + const { nonce, ciphertext, mac } = decodePayload(payload); + const { chacha_key, chacha_nonce, hmac_key } = getMessageKeys( + conversationKey, + nonce, + ); + const calculatedMac = hmacAad(hmac_key, ciphertext, nonce); + if (!equalBytes(calculatedMac, mac)) throw new Error("invalid MAC"); + const padded = chacha20(chacha_key, chacha_nonce, ciphertext); + return unpad(padded); +} + +export const v2 = { + utils: { + getConversationKey, + calcPaddedLen, + }, + encrypt, + decrypt, +}; diff --git a/src/nip44/utils.ts b/src/nip44/utils.ts new file mode 100644 index 0000000..64cb959 --- /dev/null +++ b/src/nip44/utils.ts @@ -0,0 +1,130 @@ +import type { Event } from "nostr-tools"; + +export const utf8Decoder: TextDecoder = new TextDecoder("utf-8"); +export const utf8Encoder: TextEncoder = new TextEncoder(); + +export function normalizeURL(url: string): string { + if (url.indexOf("://") === -1) url = "wss://" + url; + const p = new URL(url); + p.pathname = p.pathname.replace(/\/+/g, "/"); + if (p.pathname.endsWith("/")) p.pathname = p.pathname.slice(0, -1); + if ( + (p.port === "80" && p.protocol === "ws:") || + (p.port === "443" && p.protocol === "wss:") + ) + p.port = ""; + p.searchParams.sort(); + p.hash = ""; + return p.toString(); +} + +export function insertEventIntoDescendingList( + sortedArray: Event[], + event: Event, +): Event[] { + const [idx, found] = binarySearch(sortedArray, (b) => { + if (event.id === b.id) return 0; + if (event.created_at === b.created_at) return -1; + return b.created_at - event.created_at; + }); + if (!found) { + sortedArray.splice(idx, 0, event); + } + return sortedArray; +} + +export function insertEventIntoAscendingList( + sortedArray: Event[], + event: Event, +): Event[] { + const [idx, found] = binarySearch(sortedArray, (b) => { + if (event.id === b.id) return 0; + if (event.created_at === b.created_at) return -1; + return event.created_at - b.created_at; + }); + if (!found) { + sortedArray.splice(idx, 0, event); + } + return sortedArray; +} + +export function binarySearch( + arr: T[], + compare: (b: T) => number, +): [number, boolean] { + let start = 0; + let end = arr.length - 1; + + while (start <= end) { + const mid = Math.floor((start + end) / 2); + const cmp = compare(arr[mid]); + + if (cmp === 0) { + return [mid, true]; + } + + if (cmp < 0) { + end = mid - 1; + } else { + start = mid + 1; + } + } + + return [start, false]; +} + +export class QueueNode { + public value: V; + public next: QueueNode | null = null; + public prev: QueueNode | null = null; + + constructor(message: V) { + this.value = message; + } +} + +export class Queue { + public first: QueueNode | null; + public last: QueueNode | null; + + constructor() { + this.first = null; + this.last = null; + } + + enqueue(value: V): boolean { + const newNode = new QueueNode(value); + if (!this.last) { + // list is empty + this.first = newNode; + this.last = newNode; + } else if (this.last === this.first) { + // list has a single element + this.last = newNode; + this.last.prev = this.first; + this.first.next = newNode; + } else { + // list has elements, add as last + newNode.prev = this.last; + this.last.next = newNode; + this.last = newNode; + } + return true; + } + + dequeue(): V | null { + if (!this.first) return null; + + if (this.first === this.last) { + const target = this.first; + this.first = null; + this.last = null; + return target.value; + } + + const target = this.first; + this.first = target.next; + + return target.value; + } +}