Skip to content

Commit

Permalink
feat: add NIP 44 encryption support
Browse files Browse the repository at this point in the history
  • Loading branch information
Jonathan Staab committed Aug 16, 2023
1 parent dffd6e5 commit 9584cc6
Show file tree
Hide file tree
Showing 27 changed files with 375 additions and 27 deletions.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,11 @@
"@bitcoin-design/bitcoin-icons-react": "^0.1.9",
"@headlessui/react": "^1.7.16",
"@lightninglabs/lnc-web": "^0.2.4-alpha",
"@noble/ciphers": "^0.2.0",
"@noble/curves": "^1.1.0",
"@noble/hashes": "^1.3.1",
"@noble/secp256k1": "^2.0.0",
"@scure/base": "^1.1.1",
"@scure/bip32": "^1.3.1",
"@scure/bip39": "^1.2.1",
"@scure/btc-signer": "^0.5.1",
Expand Down
29 changes: 29 additions & 0 deletions src/common/utils/lruCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
export class LRUCache<T, U> {
map = new Map<T, U>();
keys: T[] = [];

constructor(readonly maxSize: number) {}

has(k: T) {
return this.map.has(k);
}

get(k: T) {
const v = this.map.get(k);

if (v !== undefined) {
this.keys.push(this.keys.shift() as T);
}

return v;
}

set(k: T, v: U) {
this.map.set(k, v);
this.keys.push(k);

if (this.map.size > this.maxSize) {
this.map.delete(this.keys.shift() as T);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { USER_REJECTED_ERROR } from "~/common/constants";
import utils from "~/common/lib/utils";
import { getHostFromSender } from "~/common/utils/helpers";
import state from "~/extension/background-script/state";
import i18n from "~/i18n/i18nConfig";
import { Nip44MessageDecryptGet, PermissionMethodNostr, Sender } from "~/types";

import { addPermissionFor, hasPermissionFor } from "./helpers";

const nip44DecryptOrPrompt = async (
message: Nip44MessageDecryptGet,
sender: Sender
) => {
const host = getHostFromSender(sender);
if (!host) return;

try {
const hasPermission = await hasPermissionFor(
PermissionMethodNostr["NOSTR_NIP44DECRYPT"],
host
);

if (hasPermission) {
const nostr = await state.getState().getNostr();
const response = await nostr.nip44Decrypt(
message.args.peer,
message.args.payload
);

return { data: response };
} else {
const promptResponse = await utils.openPrompt<{
confirm: boolean;
rememberPermission: boolean;
}>({
...message,
action: "public/nostr/confirm",
args: {
description: i18n.t("permissions:nostr.nip44decrypt"),
},
});

// add permission to db only if user decided to always allow this request
if (promptResponse.data.rememberPermission) {
await addPermissionFor(
PermissionMethodNostr["NOSTR_NIP44DECRYPT"],
host
);
}
if (promptResponse.data.confirm) {
const nostr = await state.getState().getNostr();
const response = await nostr.nip44Decrypt(
message.args.peer,
message.args.payload
);

return { data: response };
} else {
return { error: USER_REJECTED_ERROR };
}
}
} catch (e) {
console.error("decrypt failed", e);
if (e instanceof Error) {
return { error: e.message };
}
}
};

export default nip44DecryptOrPrompt;
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { USER_REJECTED_ERROR } from "~/common/constants";
import utils from "~/common/lib/utils";
import { getHostFromSender } from "~/common/utils/helpers";
import state from "~/extension/background-script/state";
import i18n from "~/i18n/i18nConfig";
import { Nip44MessageEncryptGet, PermissionMethodNostr, Sender } from "~/types";

import { addPermissionFor, hasPermissionFor } from "./helpers";

const nip44EncryptOrPrompt = async (
message: Nip44MessageEncryptGet,
sender: Sender
) => {
const host = getHostFromSender(sender);
if (!host) return;

try {
const hasPermission = await hasPermissionFor(
PermissionMethodNostr["NOSTR_NIP44ENCRYPT"],
host
);

if (hasPermission) {
const response = (await state.getState().getNostr()).nip44Encrypt(
message.args.peer,
message.args.plaintext,
message.args.v
);

return { data: response };
} else {
const promptResponse = await utils.openPrompt<{
confirm: boolean;
rememberPermission: boolean;
}>({
...message,
action: "public/nostr/confirm",
args: {
description: i18n.t("permissions:nostr.nip44encrypt"),
},
});

// add permission to db only if user decided to always allow this request
if (promptResponse.data.rememberPermission) {
await addPermissionFor(
PermissionMethodNostr["NOSTR_NIP44ENCRYPT"],
host
);
}
if (promptResponse.data.confirm) {
const response = (await state.getState().getNostr()).nip44Encrypt(
message.args.peer,
message.args.plaintext,
message.args.v
);

return { data: response };
} else {
return { error: USER_REJECTED_ERROR };
}
}
} catch (e) {
console.error("encrypt failed", e);
if (e instanceof Error) {
return { error: e.message };
}
}
};

export default nip44EncryptOrPrompt;
35 changes: 34 additions & 1 deletion src/extension/background-script/nostr/__test__/nostr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const carol = {
publicKey: "a8c7d70a7d2e2826ce519a0a490fb953464c9d130235c321282983cd73be333f",
};

describe("nostr", () => {
describe("nostr.nip04", () => {
test("encrypt & decrypt", async () => {
const aliceNostr = new Nostr(alice.privateKey);

Expand Down Expand Up @@ -50,3 +50,36 @@ describe("nostr", () => {
expect(decrypted).not.toMatch(message);
});
});

describe("nostr.nip44", () => {
test("encrypt & decrypt", async () => {
const aliceNostr = new Nostr(alice.privateKey);

const message = "Secret message that is sent from Alice to Bob";
const encrypted = aliceNostr.nip44Encrypt(bob.publicKey, message);

const bobNostr = new Nostr(bob.privateKey);

const decrypted = await bobNostr.nip44Decrypt(alice.publicKey, encrypted);

expect(decrypted).toMatch(message);
});

test("Carol can't decrypt Alice's message for Bob", async () => {
const aliceNostr = new Nostr(alice.privateKey);

const message = "Secret message that is sent from Alice to Bob";
const encrypted = aliceNostr.nip44Encrypt(bob.publicKey, message);

const carolNostr = new Nostr(carol.privateKey);

let decrypted;
try {
decrypted = await carolNostr.nip44Decrypt(alice.publicKey, encrypted);
} catch (e) {
decrypted = "error decrypting message";
}

expect(decrypted).not.toMatch(message);
});
});
66 changes: 63 additions & 3 deletions src/extension/background-script/nostr/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { base64 } from "@scure/base";
import { randomBytes } from "@noble/hashes/utils";
import { sha256 } from "@noble/hashes/sha256";
import { xchacha20 } from "@noble/ciphers/chacha";
import { schnorr } from "@noble/curves/secp256k1";
import * as secp256k1 from "@noble/secp256k1";
import { Buffer } from "buffer";
Expand All @@ -6,15 +10,33 @@ import { AES } from "crypto-js";
import Base64 from "crypto-js/enc-base64";
import Hex from "crypto-js/enc-hex";
import Utf8 from "crypto-js/enc-utf8";
import { LRUCache } from "~/common/utils/lruCache";
import { Event } from "~/extension/providers/nostr/types";

import { getEventHash, signEvent } from "../actions/nostr/helpers";

const utf8Decoder = new TextDecoder();

const utf8Encoder = new TextEncoder();

class Nostr {
privateKey: string;
nip44SharedSecretCache = new LRUCache<string, Uint8Array>(100);

constructor(readonly privateKey: string) {}

// Deriving shared secret is an expensive computation
getNip44SharedSecret(pk: string) {
let key = this.nip44SharedSecretCache.get(pk);

constructor(privateKey: string) {
this.privateKey = privateKey;
if (!key) {
key = sha256(
secp256k1.getSharedSecret(this.privateKey, "02" + pk).subarray(1, 33)
);

this.nip44SharedSecretCache.set(pk, key);
}

return key;
}

getPublicKey() {
Expand Down Expand Up @@ -69,6 +91,44 @@ class Nostr {
return Utf8.stringify(decrypted);
}

nip44Encrypt(pubkey: string, text: string, v = 1) {
if (v !== 1) {
throw new Error("NIP44: unknown encryption version");
}

const nonce = randomBytes(24);
const plaintext = utf8Encoder.encode(text);
const key = this.getNip44SharedSecret(pubkey);
const ciphertext = xchacha20(key, nonce, plaintext);

const payload = new Uint8Array(25 + ciphertext.length);
payload.set([v], 0);
payload.set(nonce, 1);
payload.set(ciphertext, 25);

return base64.encode(payload);
}

nip44Decrypt(pubkey: string, payload: string) {
let data;
try {
data = base64.decode(payload);
} catch (e) {
throw new Error(`NIP44: failed to base64 decode payload: ${e}`);
}

if (data[0] !== 1) {
throw new Error(`NIP44: unknown encryption version: ${data[0]}`);
}

const nonce = data.slice(1, 25);
const ciphertext = data.slice(25);
const key = this.getNip44SharedSecret(pubkey);
const plaintext = xchacha20(key, nonce, ciphertext);

return utf8Decoder.decode(plaintext);
}

getEventHash(event: Event) {
return getEventHash(event);
}
Expand Down
23 changes: 23 additions & 0 deletions src/extension/providers/nostr/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ declare global {

export default class NostrProvider {
nip04 = new Nip04(this);
nip44 = new Nip44(this);
enabled: boolean;
private _eventEmitter: EventEmitter;

Expand Down Expand Up @@ -89,3 +90,25 @@ class Nip04 {
return this.provider.execute("decryptOrPrompt", { peer, ciphertext });
}
}

class Nip44 {
provider: NostrProvider;

constructor(provider: NostrProvider) {
this.provider = provider;
}

async encrypt(peer: string, plaintext: string, v: number) {
await this.provider.enable();
return this.provider.execute("nip44EncryptOrPrompt", {
peer,
plaintext,
v,
});
}

async decrypt(peer: string, payload: string) {
await this.provider.enable();
return this.provider.execute("nip44DecryptOrPrompt", { peer, payload });
}
}
4 changes: 3 additions & 1 deletion src/i18n/locales/cs/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -855,9 +855,11 @@
"invoice": "Vytvořit novou platební fakturu."
},
"nostr": {
"nip04encrypt": "Zašifrovat data.",
"getpublickey": "Přečíst svůj veřejný klíč.",
"nip04decrypt": "Dešifrovat data.",
"nip04encrypt": "Zašifrovat data.",
"nip44decrypt": "Dešifrovat data.",
"nip44encrypt": "Zašifrovat data.",
"signmessage": "Podepsat zprávu klíčem."
},
"lnc": {
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/locales/da/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -802,8 +802,10 @@
"permissions": {
"nostr": {
"getpublickey": "Læs din offentlige nøgle.",
"nip04encrypt": "Krypter data.",
"nip04decrypt": "De-krypter data.",
"nip04encrypt": "Krypter data.",
"nip44decrypt": "De-krypter data.",
"nip44encrypt": "Krypter data.",
"signmessage": "Underskriv meddelelse med din nøgle."
},
"commando": {
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/de/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -1129,6 +1129,8 @@
"getpublickey": "Lese deinen öffentlichen Schlüssel.",
"nip04decrypt": "Daten entschlüsseln.",
"nip04encrypt": "Daten verschlüsseln.",
"nip44decrypt": "Daten entschlüsseln.",
"nip44encrypt": "Daten verschlüsseln.",
"signmessage": "Unterschreibe deine Nachricht mit deinem Schlüssel."
},
"lnc": {
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -1079,6 +1079,8 @@
"getpublickey": "Read your public key.",
"nip04encrypt": "Encrypt data.",
"nip04decrypt": "Decrypt data.",
"nip44encrypt": "Encrypt data.",
"nip44decrypt": "Decrypt data.",
"signmessage": "Sign message with your key."
},
"commando": {
Expand Down
Loading

0 comments on commit 9584cc6

Please sign in to comment.