feat: nip44

This commit is contained in:
2023-06-14 15:03:07 +01:00
parent ecd957792d
commit c2a3a706de
10 changed files with 262 additions and 57 deletions

View File

@ -160,7 +160,7 @@ export class NostrConnectWallet implements LNWallet {
});
const eb = new EventBuilder();
eb.kind(23194 as EventKind)
.content(await EventExt.encryptData(payload, this.#config.walletPubkey, this.#config.secret))
.content(await EventExt.encryptDm(payload, this.#config.secret, this.#config.walletPubkey))
.tag(["p", this.#config.walletPubkey]);
const evCommand = await eb.buildAndSign(this.#config.secret);
@ -182,7 +182,7 @@ export class NostrConnectWallet implements LNWallet {
return await new Promise<T>((resolve, reject) => {
this.#commandQueue.set(evCommand.id, {
resolve: async (o: string) => {
const reply = JSON.parse(await EventExt.decryptData(o, this.#config.secret, this.#config.walletPubkey));
const reply = JSON.parse(await EventExt.decryptDm(o, this.#config.secret, this.#config.walletPubkey));
debug("NWC")("%o", reply);
resolve(reply);
},

View File

@ -17,6 +17,7 @@
],
"devDependencies": {
"@jest/globals": "^29.5.0",
"@peculiar/webcrypto": "^1.4.3",
"@types/jest": "^29.5.1",
"jest": "^29.5.0",
"jest-environment-jsdom": "^29.5.0",
@ -25,7 +26,8 @@
},
"dependencies": {
"@noble/curves": "^1.0.0",
"@protobufjs/base64": "^1.1.2",
"@scure/base": "^1.1.1",
"@stablelib/xchacha20": "^1.0.1",
"bech32": "^2.0.0",
"debug": "^4.3.4",
"uuid": "^9.0.0"

View File

@ -1,8 +1,9 @@
import * as secp from "@noble/curves/secp256k1";
import * as utils from "@noble/curves/abstract/utils";
import { EventKind, HexKey, NostrEvent } from ".";
import base64 from "@protobufjs/base64";
import { sha256, unixNow } from "./Utils";
import { Nip4WebCryptoEncryptor } from "./impl/nip4";
export interface Tag {
key: string
@ -135,58 +136,15 @@ export abstract class EventExt {
return ret;
}
/**
* Encrypt the given message content
*/
static async encryptData(content: string, pubkey: HexKey, privkey: HexKey) {
const key = await this.#getDmSharedKey(pubkey, privkey);
const iv = window.crypto.getRandomValues(new Uint8Array(16));
const data = new TextEncoder().encode(content);
const result = await window.crypto.subtle.encrypt(
{
name: "AES-CBC",
iv: iv,
},
key,
data
);
const uData = new Uint8Array(result);
return `${base64.encode(uData, 0, result.byteLength)}?iv=${base64.encode(iv, 0, 16)}`;
}
/**
* Decrypt the content of the message
*/
static async decryptData(cyphertext: string, privkey: HexKey, pubkey: HexKey) {
const key = await this.#getDmSharedKey(pubkey, privkey);
const cSplit = cyphertext.split("?iv=");
const data = new Uint8Array(base64.length(cSplit[0]));
base64.decode(cSplit[0], data, 0);
const iv = new Uint8Array(base64.length(cSplit[1]));
base64.decode(cSplit[1], iv, 0);
const result = await window.crypto.subtle.decrypt(
{
name: "AES-CBC",
iv: iv,
},
key,
data
);
return new TextDecoder().decode(result);
}
/**
* Decrypt the content of this message in place
*/
static async decryptDm(content: string, privkey: HexKey, pubkey: HexKey) {
return await this.decryptData(content, privkey, pubkey);
const enc = new Nip4WebCryptoEncryptor();
const key = enc.getSharedSecret(privkey, pubkey);
return await enc.decryptData(content, key);
}
static async #getDmSharedKey(pubkey: HexKey, privkey: HexKey) {
const sharedPoint = secp.secp256k1.getSharedSecret(privkey, "02" + pubkey);
const sharedX = sharedPoint.slice(1, 33);
return await window.crypto.subtle.importKey("raw", sharedX, { name: "AES-CBC" }, false, ["encrypt", "decrypt"]);
static async encryptDm(content: string, privKey: HexKey, pubKey: HexKey) {
const enc = new Nip4WebCryptoEncryptor();
const secret = enc.getSharedSecret(privKey, pubKey);
return await enc.encryptData(content, secret);
}
}

View File

@ -87,7 +87,7 @@ export class EventPublisher {
unwrap(window.nostr?.nip04?.encrypt).call(window.nostr?.nip04, key, content)
);
} else if (this.#privateKey) {
return await EventExt.encryptData(content, key, this.#privateKey);
return await EventExt.encryptDm(content, this.#privateKey, key);
} else {
throw new Error("Can't encrypt content, no private keys available");
}

View File

@ -0,0 +1,52 @@
import { MessageEncryptor } from "index";
import { base64 } from "@scure/base";
import { secp256k1 } from "@noble/curves/secp256k1";
export class Nip4WebCryptoEncryptor implements MessageEncryptor {
getSharedSecret(privateKey: string, publicKey: string) {
const sharedPoint = secp256k1.getSharedSecret(privateKey, "02" + publicKey);
const sharedX = sharedPoint.slice(1, 33);
return sharedX;
}
async encryptData(content: string, sharedSecet: Uint8Array) {
const key = await this.#importKey(sharedSecet);
const iv = window.crypto.getRandomValues(new Uint8Array(16));
const data = new TextEncoder().encode(content);
const result = await window.crypto.subtle.encrypt(
{
name: "AES-CBC",
iv: iv,
},
key,
data
);
const uData = new Uint8Array(result);
return `${base64.encode(uData)}?iv=${base64.encode(iv)}`;
}
/**
* Decrypt the content of the message
*/
async decryptData(cyphertext: string, sharedSecet: Uint8Array) {
const key = await this.#importKey(sharedSecet);
const cSplit = cyphertext.split("?iv=");
const data = base64.decode(cSplit[0]);
const iv = base64.decode(cSplit[1]);
const result = await window.crypto.subtle.decrypt(
{
name: "AES-CBC",
iv: iv,
},
key,
data
);
return new TextDecoder().decode(result);
}
async #importKey(sharedSecet: Uint8Array) {
return await window.crypto.subtle.importKey("raw", sharedSecet, { name: "AES-CBC" }, false, ["encrypt", "decrypt"]);
}
}

View File

@ -0,0 +1,40 @@
import { MessageEncryptor } from "index";
import { base64 } from "@scure/base";
import { randomBytes } from '@noble/hashes/utils'
import { streamXOR as xchacha20 } from '@stablelib/xchacha20'
import { secp256k1 } from "@noble/curves/secp256k1";
import { sha256 } from '@noble/hashes/sha256'
export enum Nip44Version {
Reserved = 0x00,
XChaCha20 = 0x01
}
export class Nip44Encryptor implements MessageEncryptor {
getSharedSecret(privateKey: string, publicKey: string) {
const key = secp256k1.getSharedSecret(privateKey, '02' + publicKey)
return sha256(key.slice(1, 33));
}
encryptData(content: string, sharedSecret: Uint8Array) {
const nonce = randomBytes(24)
const plaintext = new TextEncoder().encode(content)
const ciphertext = xchacha20(sharedSecret, nonce, plaintext, plaintext);
const ctb64 = base64.encode(Uint8Array.from(ciphertext))
const nonceb64 = base64.encode(nonce)
return JSON.stringify({ ciphertext: ctb64, nonce: nonceb64, v: Nip44Version.XChaCha20 })
}
decryptData(cyphertext: string, sharedSecret: Uint8Array) {
const dt = JSON.parse(cyphertext)
if (dt.v !== 1) throw new Error('NIP44: unknown encryption version')
const ciphertext = base64.decode(dt.ciphertext)
const nonce = base64.decode(dt.nonce)
const plaintext = xchacha20(sharedSecret, nonce, ciphertext, ciphertext)
const text = new TextDecoder().decode(plaintext)
return text;
}
}

View File

@ -19,6 +19,8 @@ export * from "./EventBuilder";
export * from "./NostrLink";
export * from "./cache";
export * from "./ProfileCache";
export * from "./impl/nip4";
export * from "./impl/nip44";
export interface SystemInterface {
/**
@ -40,4 +42,10 @@ export interface SystemSnapshot {
filters: Array<ReqFilter>;
subFilters: Array<ReqFilter>;
}>;
}
export interface MessageEncryptor {
getSharedSecret(privateKey: string, publicKey: string): Promise<Uint8Array> | Uint8Array
encryptData(plaintext: string, sharedSecet: Uint8Array): Promise<string> | string
decryptData(cyphertext: string, sharedSecet: Uint8Array): Promise<string> | string
}

View File

@ -0,0 +1,45 @@
import { schnorr, secp256k1 } from "@noble/curves/secp256k1";
import { Nip4WebCryptoEncryptor } from "../src/impl/nip4";
import { Nip44Encryptor } from "../src/impl/nip44";
import { bytesToHex } from "@noble/curves/abstract/utils";
const aKey = secp256k1.utils.randomPrivateKey();
const aPubKey = schnorr.getPublicKey(aKey);
const bKey = secp256k1.utils.randomPrivateKey();
const bPubKey = schnorr.getPublicKey(bKey);
describe("NIP-04", () => {
it("should encrypt/decrypt", async () => {
const msg = "test hello, 123";
const enc = new Nip4WebCryptoEncryptor();
const sec = enc.getSharedSecret(bytesToHex(aKey), bytesToHex(bPubKey));
const ciphertext = await enc.encryptData(msg, sec);
expect(ciphertext).toMatch(/^.*\?iv=.*$/i);
const dec = new Nip4WebCryptoEncryptor();
const sec2 = enc.getSharedSecret(bytesToHex(bKey), bytesToHex(aPubKey));
const plaintext = await dec.decryptData(ciphertext, sec2);
expect(plaintext).toEqual(msg);
})
})
describe("NIP-44", () => {
it("should encrypt/decrypt", () => {
const msg = "test hello, 123";
const enc = new Nip44Encryptor();
const sec = enc.getSharedSecret(bytesToHex(aKey), bytesToHex(bPubKey));
const ciphertext = enc.encryptData(msg, sec);
const jObj = JSON.parse(ciphertext);
expect(jObj).toHaveProperty("ciphertext")
expect(jObj).toHaveProperty("nonce")
expect(jObj.v).toBe(1);
const dec = new Nip44Encryptor();
const sec2 = enc.getSharedSecret(bytesToHex(bKey), bytesToHex(aPubKey));
const plaintext = dec.decryptData(ciphertext, sec2);
expect(plaintext).toEqual(msg);
})
})

View File

@ -1,3 +1,5 @@
import { TextEncoder, TextDecoder } from "util";
import { Crypto } from "@peculiar/webcrypto";
Object.assign(global, { TextDecoder, TextEncoder });
Object.assign(globalThis.window.crypto, new Crypto());