Skip to content

Commit

Permalink
feat: add nip 44 and versioning support
Browse files Browse the repository at this point in the history
  • Loading branch information
im-adithya committed Nov 29, 2024
1 parent 316fca4 commit 7bdddc4
Show file tree
Hide file tree
Showing 3 changed files with 395 additions and 6 deletions.
104 changes: 98 additions & 6 deletions src/NWCClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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<string, NWCOptions> = {
alby: {
Expand All @@ -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/...)
Expand Down Expand Up @@ -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<string> {
return Promise.resolve(this.publicKey);
}
Expand All @@ -340,15 +353,27 @@ 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;
}

async decrypt(pubkey: string, content: string) {
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;
}

Expand Down Expand Up @@ -455,6 +480,7 @@ export class NWCClient {
}

async getWalletServiceInfo(): Promise<{
versions: string[];
capabilities: Nip47Capability[];
notifications: Nip47NotificationType[];
}> {
Expand All @@ -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(" ") ||
Expand Down Expand Up @@ -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<number> | 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],
},
Expand Down Expand Up @@ -765,6 +793,7 @@ export class NWCClient {
resultValidator: (result: T) => boolean,
): Promise<T> {
await this._checkConnected();
await this._checkCompatibility();
return new Promise<T>((resolve, reject) => {
(async () => {
const command = {
Expand All @@ -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,
};
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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,
};
Expand Down Expand Up @@ -1034,6 +1070,7 @@ export class NWCClient {
})();
});
}

private async _checkConnected() {
if (!this.secret) {
throw new Error("Missing secret key");
Expand All @@ -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;
}
}
167 changes: 167 additions & 0 deletions src/nip44/index.ts
Original file line number Diff line number Diff line change
@@ -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,
};
Loading

0 comments on commit 7bdddc4

Please sign in to comment.