Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NIP44 Encryption Support #3075

Merged
merged 14 commits into from
Mar 13, 2024
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,12 @@
"@getalby/sdk": "^3.4.0",
"@headlessui/react": "^1.7.18",
"@lightninglabs/lnc-web": "^0.2.4-alpha",
"@noble/ciphers": "^0.5.1",
"@noble/curves": "^1.3.0",
"@noble/hashes": "^1.3.3",
"@noble/secp256k1": "^2.0.0",
"@popicons/react": "^0.0.9",
"@scure/base": "^1.1.5",
"@scure/bip32": "^1.3.3",
"@scure/bip39": "^1.2.2",
"@tailwindcss/forms": "^0.5.7",
Expand Down
33 changes: 33 additions & 0 deletions src/common/utils/lruCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
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(k as T);

if (this.keys.length > this.maxSize * 2) {
this.keys.splice(-this.maxSize);
}
}

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
Expand Up @@ -20,7 +20,7 @@ const decryptOrPrompt = async (message: MessageDecryptGet, sender: Sender) => {

if (hasPermission) {
const nostr = await state.getState().getNostr();
const response = await nostr.decrypt(
const response = await nostr.nip04Decrypt(
message.args.peer,
message.args.ciphertext
);
Expand All @@ -44,7 +44,7 @@ const decryptOrPrompt = async (message: MessageDecryptGet, sender: Sender) => {
}
if (promptResponse.data.confirm) {
const nostr = await state.getState().getNostr();
const response = await nostr.decrypt(
const response = await nostr.nip04Decrypt(
message.args.peer,
message.args.ciphertext
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ const encryptOrPrompt = async (message: MessageEncryptGet, sender: Sender) => {
);

if (hasPermission) {
const response = (await state.getState().getNostr()).encrypt(
const nostr = await state.getState().getNostr();
const response = await nostr.nip04Encrypt(
message.args.peer,
message.args.plaintext
);
Expand Down Expand Up @@ -48,7 +49,8 @@ const encryptOrPrompt = async (message: MessageEncryptGet, sender: Sender) => {
);
}
if (promptResponse.data.confirm) {
const response = (await state.getState().getNostr()).encrypt(
const nostr = await state.getState().getNostr();
const response = await nostr.nip04Encrypt(
message.args.peer,
message.args.plaintext
);
Expand Down
4 changes: 4 additions & 0 deletions src/extension/background-script/actions/nostr/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import getPrivateKey from "./getPrivateKey";
import getPublicKeyOrPrompt from "./getPublicKeyOrPrompt";
import getRelays from "./getRelays";
import isEnabled from "./isEnabled";
import nip44DecryptOrPrompt from "./nip44DecryptOrPrompt";
import nip44EncryptOrPrompt from "./nip44EncryptOrPrompt";
import removePrivateKey from "./removePrivateKey";
import setPrivateKey from "./setPrivateKey";
import signEventOrPrompt from "./signEventOrPrompt";
Expand All @@ -23,6 +25,8 @@ export {
getPublicKeyOrPrompt,
getRelays,
isEnabled,
nip44DecryptOrPrompt,
nip44EncryptOrPrompt,
removePrivateKey,
setPrivateKey,
signEventOrPrompt,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { USER_REJECTED_ERROR } from "~/common/constants";
import utils from "~/common/lib/utils";
import { getHostFromSender } from "~/common/utils/helpers";
import {
addPermissionFor,
hasPermissionFor,
} from "~/extension/background-script/permissions";
import state from "~/extension/background-script/state";
import { MessageNip44DecryptGet, PermissionMethodNostr, Sender } from "~/types";

const nip44DecryptOrPrompt = async (
message: MessageNip44DecryptGet,
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.ciphertext
);

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

// 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.ciphertext
);

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,72 @@
import { USER_REJECTED_ERROR } from "~/common/constants";
import nostr from "~/common/lib/nostr";
import utils from "~/common/lib/utils";
import { getHostFromSender } from "~/common/utils/helpers";
import {
addPermissionFor,
hasPermissionFor,
} from "~/extension/background-script/permissions";
import state from "~/extension/background-script/state";
import { MessageNip44EncryptGet, PermissionMethodNostr, Sender } from "~/types";

const nip44EncryptOrPrompt = async (
message: MessageNip44EncryptGet,
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
);
return { data: response };
} else {
const promptResponse = await utils.openPrompt<{
confirm: boolean;
rememberPermission: boolean;
}>({
...message,
action: "public/nostr/confirmEncrypt",
args: {
encrypt: {
recipientNpub: nostr.hexToNip19(message.args.peer, "npub"),
message: message.args.plaintext,
},
},
});

// 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
);

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;
43 changes: 38 additions & 5 deletions src/extension/background-script/nostr/__test__/nostr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,16 @@ const carol = {
publicKey: "a8c7d70a7d2e2826ce519a0a490fb953464c9d130235c321282983cd73be333f",
};

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

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

const bobNostr = new Nostr(bob.privateKey);

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

expect(decrypted).toMatch(message);
});
Expand All @@ -36,13 +36,46 @@ describe("nostr", () => {
const aliceNostr = new Nostr(alice.privateKey);

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

const carolNostr = new Nostr(carol.privateKey);

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

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";
}
Expand Down
31 changes: 26 additions & 5 deletions src/extension/background-script/nostr/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,28 @@ 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 { nip44 } from "./nip44";

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

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

constructor(privateKey: string) {
this.privateKey = privateKey;
constructor(readonly privateKey: string) {}

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

if (!key) {
key = nip44.utils.getConversationKey(this.privateKey, peerPubkey);

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

return key;
}

getPublicKey() {
Expand All @@ -40,7 +53,7 @@ class Nostr {
return signedHex;
}

encrypt(pubkey: string, text: string) {
nip04Encrypt(pubkey: string, text: string) {
const key = secp256k1.getSharedSecret(this.privateKey, "02" + pubkey);
const normalizedKey = Buffer.from(key.slice(1, 33));
const hexNormalizedKey = secp256k1.etc.bytesToHex(normalizedKey);
Expand All @@ -55,7 +68,7 @@ class Nostr {
)}`;
}

async decrypt(pubkey: string, ciphertext: string) {
async nip04Decrypt(pubkey: string, ciphertext: string) {
const [cip, iv] = ciphertext.split("?iv=");
const key = secp256k1.getSharedSecret(this.privateKey, "02" + pubkey);
const normalizedKey = Buffer.from(key.slice(1, 33));
Expand All @@ -69,6 +82,14 @@ class Nostr {
return Utf8.stringify(decrypted);
}

nip44Encrypt(peer: string, plaintext: string) {
pavanjoshi914 marked this conversation as resolved.
Show resolved Hide resolved
return nip44.encrypt(plaintext, this.getNip44SharedSecret(peer));
}

nip44Decrypt(peer: string, ciphertext: string) {
return nip44.decrypt(ciphertext, this.getNip44SharedSecret(peer));
}

getEventHash(event: Event) {
return getEventHash(event);
}
Expand Down
Loading
Loading