feat: add support for nip44

This commit is contained in:
Ren Amamiya
2026-04-08 11:54:00 +07:00
parent 387796faa3
commit 72b9dcddc1
20 changed files with 2447 additions and 309 deletions

View File

@@ -1,229 +1,273 @@
import browser from 'webextension-polyfill'
import browser from "webextension-polyfill";
import {
validateEvent,
finalizeEvent,
getEventHash,
getPublicKey,
nip19,
utils
} from 'nostr-tools'
import { nip04 } from 'nostr-tools'
utils,
} from "nostr-tools";
import { nip04 } from "nostr-tools";
import * as nip44 from "nostr-tools/nip44";
import { Mutex } from "async-mutex";
import { LRUCache } from "./utils";
const { hexToBytes } = utils
import { Mutex } from 'async-mutex'
const { hexToBytes } = utils;
import {
NO_PERMISSIONS_REQUIRED,
getPermissionStatus,
updatePermission,
showNotification
} from './common'
showNotification,
getPosition,
} from "./common";
const { encrypt, decrypt } = nip04
const { encrypt, decrypt } = nip04;
let openPrompt = null
const promptMutex = new Mutex()
let releasePromptMutex = () => {}
let openPrompt = null;
const promptMutex = new Mutex();
let releasePromptMutex = () => {};
const secretsCache = new LRUCache(100);
const previousSk = null;
function getSharedSecret(sk, peer) {
if (previousSk !== sk) {
secretsCache.clear();
}
let key = secretsCache.get(peer);
if (!key) {
key = nip44.v2.utils.getConversationKey(sk, peer);
secretsCache.set(peer, key);
}
return key;
}
const width = 440;
const height = 420;
browser.runtime.onInstalled.addListener((_, __, reason) => {
if (reason === 'install') browser.runtime.openOptionsPage()
})
if (reason === "install") browser.runtime.openOptionsPage();
});
browser.runtime.onMessage.addListener(async (req, sender) => {
const { prompt } = req
const { prompt } = req;
if (prompt) {
handlePromptMessage(req, sender)
handlePromptMessage(req, sender);
} else {
return handleContentScriptMessage(req)
return handleContentScriptMessage(req);
}
})
});
browser.runtime.onMessageExternal.addListener(
async ({ type, params }, sender) => {
const extensionId = new URL(sender.url).host
return handleContentScriptMessage({ type, params, host: extensionId })
const extensionId = new URL(sender.url).host;
return handleContentScriptMessage({ type, params, host: extensionId });
}
)
);
browser.windows.onRemoved.addListener((_windowId) => {
if (openPrompt) {
// calling this with a simple "no" response will not store anything, so it's fine
// it will just return a failure
handlePromptMessage({ accept: false }, null)
handlePromptMessage({ accept: false }, null);
}
})
});
async function handleContentScriptMessage({ type, params, host }) {
if (NO_PERMISSIONS_REQUIRED[type]) {
// authorized, and we won't do anything with private key here, so do a separate handler
switch (type) {
case 'replaceURL': {
case "peekPublicKey": {
const allowed = await getPermissionStatus(host, "getPublicKey");
if (allowed === true) return performOperation("getPublicKey", params);
return "";
}
case "replaceURL": {
const { protocol_handler: ph } = await browser.storage.local.get([
'protocol_handler'
])
if (!ph) return false
"protocol_handler",
]);
if (!ph) return false;
const { url } = params
const raw = url.split('nostr:')[1]
const { type, data } = nip19.decode(raw)
const { url } = params;
const raw = url.split("nostr:")[1];
const { type, data } = nip19.decode(raw);
const replacements = {
raw,
hrp: type,
hex:
type === 'npub' || type === 'note'
type === "npub" || type === "note"
? data
: type === 'nprofile'
? data.pubkey
: type === 'nevent'
? data.id
: null,
p_or_e: { npub: 'p', note: 'e', nprofile: 'p', nevent: 'e' }[type],
u_or_n: { npub: 'u', note: 'n', nprofile: 'u', nevent: 'n' }[type],
relay0: type === 'nprofile' ? data.relays[0] : null,
relay1: type === 'nprofile' ? data.relays[1] : null,
relay2: type === 'nprofile' ? data.relays[2] : null
}
let result = ph
: type === "nprofile"
? data.pubkey
: type === "nevent"
? data.id
: null,
p_or_e: { npub: "p", note: "e", nprofile: "p", nevent: "e" }[type],
u_or_n: { npub: "u", note: "n", nprofile: "u", nevent: "n" }[type],
relay0: type === "nprofile" ? data.relays[0] : null,
relay1: type === "nprofile" ? data.relays[1] : null,
relay2: type === "nprofile" ? data.relays[2] : null,
};
let result = ph;
Object.entries(replacements).forEach(([pattern, value]) => {
result = result.replace(new RegExp(`{ *${pattern} *}`, 'g'), value)
})
result = result.replace(new RegExp(`{ *${pattern} *}`, "g"), value);
});
return result
return result;
}
}
return
return;
} else {
// acquire mutex here before reading policies
releasePromptMutex = await promptMutex.acquire()
releasePromptMutex = await promptMutex.acquire();
const allowed = await getPermissionStatus(
host,
type,
type === 'signEvent' ? params.event : undefined
)
type === "signEvent" ? params.event : undefined
);
if (allowed === true) {
// authorized, proceed
releasePromptMutex()
showNotification(host, allowed, type, params)
releasePromptMutex();
showNotification(host, allowed, type, params);
} else if (allowed === false) {
// denied, just refuse immediately
releasePromptMutex()
showNotification(host, allowed, type, params)
releasePromptMutex();
showNotification(host, allowed, type, params);
return {
error: 'denied'
}
error: "denied",
};
} else {
// ask for authorization
try {
const id = Math.random().toString().slice(4)
const id = Math.random().toString().slice(4);
const qs = new URLSearchParams({
host,
id,
params: JSON.stringify(params),
type
})
type,
});
// prompt will be resolved with true or false
const accept = await new Promise((resolve, reject) => {
openPrompt = { resolve, reject }
openPrompt = { resolve, reject };
const url = `${browser.runtime.getURL(
'prompt.html'
)}?${qs.toString()}`
"prompt.html"
)}?${qs.toString()}`;
// center prompt
const { top, left } = getPosition(width, height);
if (browser.windows) {
browser.windows.create({
url,
type: 'popup',
width: 600,
height: 600
})
type: "popup",
width: width,
height: height,
top: top,
left: left,
});
} else {
browser.tabs.create({
url,
active: true
})
active: true,
});
}
})
});
// denied, stop here
if (!accept) return { error: 'denied' }
if (!accept) return { error: { message: "denied" } };
} catch (err) {
// errored, stop here
releasePromptMutex()
releasePromptMutex();
return {
error: `error: ${err}`
}
error: { message: err.message, stack: err.stack },
};
}
}
}
// if we're here this means it was accepted
const results = await browser.storage.local.get('private_key')
const results = await browser.storage.local.get("private_key");
if (!results?.private_key) {
return { error: 'no private key found' }
return { error: "no private key found" };
}
const sk = results.private_key
const sk = results.private_key;
try {
switch (type) {
case 'getPublicKey': {
return getPublicKey(hexToBytes(sk))
case "getPublicKey": {
return getPublicKey(hexToBytes(sk));
}
case 'getRelays': {
const results = await browser.storage.local.get('relays')
return results.relays || {}
case "getRelays": {
const results = await browser.storage.local.get("relays");
return results.relays || {};
}
case 'signEvent': {
const { event } = params
case "signEvent": {
const { event } = params;
if (!event.pubkey) event.pubkey = getPublicKey(hexToBytes(sk))
if (!event.id) event.id = getEventHash(event)
if (!event.pubkey) event.pubkey = getPublicKey(hexToBytes(sk));
if (!event.id) event.id = getEventHash(event);
if (!validateEvent(event))
return { error: { message: 'invalid event' } }
return { error: { message: "invalid event" } };
const signedEvent = finalizeEvent(event, hexToBytes(sk))
return signedEvent
const signedEvent = finalizeEvent(event, hexToBytes(sk));
return signedEvent;
}
case 'nip04.encrypt': {
const { peer, plaintext } = params
return encrypt(sk, peer, plaintext)
case "nip04.encrypt": {
const { peer, plaintext } = params;
return encrypt(sk, peer, plaintext);
}
case 'nip04.decrypt': {
const { peer, ciphertext } = params
return decrypt(sk, peer, ciphertext)
case "nip04.decrypt": {
const { peer, ciphertext } = params;
return decrypt(sk, peer, ciphertext);
}
case "nip44.encrypt": {
const { peer, plaintext } = params;
const key = getSharedSecret(sk, peer);
return nip44.v2.encrypt(plaintext, key);
}
case "nip44.decrypt": {
const { peer, ciphertext } = params;
const key = getSharedSecret(sk, peer);
return nip44.v2.decrypt(ciphertext, key);
}
}
} catch (error) {
return { error: { message: error.message, stack: error.stack } }
return { error: { message: error.message, stack: error.stack } };
}
}
async function handlePromptMessage({ host, type, accept, conditions }, sender) {
// return response
openPrompt?.resolve?.(accept)
openPrompt?.resolve?.(accept);
// update policies
if (conditions) {
await updatePermission(host, type, accept, conditions)
await updatePermission(host, type, accept, conditions);
}
// cleanup this
openPrompt = null
openPrompt = null;
// release mutex here after updating policies
releasePromptMutex()
releasePromptMutex();
// close prompt
if (sender) {
if (browser.windows) {
browser.windows.remove(sender.tab.windowId)
browser.windows.remove(sender.tab.windowId);
} else {
// Android Firefox
browser.tabs.remove(sender.tab.id)
browser.tabs.remove(sender.tab.id);
}
}
}