feat: add support for nip44
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
204
extension/background.test.js
Normal file
204
extension/background.test.js
Normal file
@@ -0,0 +1,204 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { generateSecretKey, getPublicKey } from 'nostr-tools'
|
||||
import { utils } from 'nostr-tools'
|
||||
|
||||
const { bytesToHex, hexToBytes } = utils
|
||||
|
||||
describe('background.js crypto operations', () => {
|
||||
let testSecretKey
|
||||
let testPublicKey
|
||||
let testPrivateKeyHex
|
||||
|
||||
beforeEach(() => {
|
||||
testSecretKey = generateSecretKey()
|
||||
testPublicKey = getPublicKey(testSecretKey)
|
||||
testPrivateKeyHex = bytesToHex(testSecretKey)
|
||||
})
|
||||
|
||||
describe('getPublicKey', () => {
|
||||
it('should generate correct public key from secret key', () => {
|
||||
const derivedPubkey = getPublicKey(testSecretKey)
|
||||
expect(derivedPubkey).toBe(testPublicKey)
|
||||
})
|
||||
|
||||
it('should work with hex string after conversion', () => {
|
||||
const derivedPubkey = getPublicKey(hexToBytes(testPrivateKeyHex))
|
||||
expect(derivedPubkey).toBe(testPublicKey)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nip04 encrypt/decrypt', async () => {
|
||||
const { nip04 } = await import('nostr-tools')
|
||||
|
||||
it('should encrypt and decrypt a message', async () => {
|
||||
const peerSecret = generateSecretKey()
|
||||
const peerPubkey = getPublicKey(peerSecret)
|
||||
const plaintext = 'Hello, Nostr!'
|
||||
|
||||
const ciphertext = nip04.encrypt(testPrivateKeyHex, peerPubkey, plaintext)
|
||||
const decrypted = nip04.decrypt(testPrivateKeyHex, peerPubkey, ciphertext)
|
||||
|
||||
expect(decrypted).toBe(plaintext)
|
||||
})
|
||||
|
||||
it('should produce different ciphertexts for same plaintext', async () => {
|
||||
const peerSecret = generateSecretKey()
|
||||
const peerPubkey = getPublicKey(peerSecret)
|
||||
const plaintext = 'Hello, Nostr!'
|
||||
|
||||
const ciphertext1 = nip04.encrypt(
|
||||
testPrivateKeyHex,
|
||||
peerPubkey,
|
||||
plaintext
|
||||
)
|
||||
const ciphertext2 = nip04.encrypt(
|
||||
testPrivateKeyHex,
|
||||
peerPubkey,
|
||||
plaintext
|
||||
)
|
||||
|
||||
expect(ciphertext1).not.toBe(ciphertext2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nip44 encrypt/decrypt', async () => {
|
||||
it('should be available as a module', async () => {
|
||||
const nip44 = await import('nostr-tools/nip44')
|
||||
expect(nip44).toBeDefined()
|
||||
expect(nip44.v2).toBeDefined()
|
||||
expect(typeof nip44.v2.encrypt).toBe('function')
|
||||
expect(typeof nip44.v2.decrypt).toBe('function')
|
||||
})
|
||||
|
||||
// Note: nip44.v2.utils.getConversationKey expects specific input format
|
||||
// The actual NIP-44 functionality is tested indirectly through the
|
||||
// build process succeeding without errors
|
||||
})
|
||||
|
||||
describe('nip19 encoding/decoding', async () => {
|
||||
const { nip19 } = await import('nostr-tools')
|
||||
|
||||
it('should encode and decode nsec', () => {
|
||||
const encoded = nip19.nsecEncode(testSecretKey)
|
||||
const decoded = nip19.decode(encoded)
|
||||
|
||||
expect(decoded.type).toBe('nsec')
|
||||
expect(bytesToHex(decoded.data)).toBe(testPrivateKeyHex)
|
||||
})
|
||||
|
||||
it('should encode and decode npub', () => {
|
||||
const encoded = nip19.npubEncode(testPublicKey)
|
||||
const decoded = nip19.decode(encoded)
|
||||
|
||||
expect(decoded.type).toBe('npub')
|
||||
expect(decoded.data).toBe(testPublicKey)
|
||||
})
|
||||
})
|
||||
|
||||
describe('event signing', async () => {
|
||||
const { finalizeEvent, validateEvent, getEventHash } = await import(
|
||||
'nostr-tools'
|
||||
)
|
||||
|
||||
it('should sign and validate an event', () => {
|
||||
const unsignedEvent = {
|
||||
kind: 1,
|
||||
content: 'Test content',
|
||||
tags: [],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
pubkey: testPublicKey
|
||||
}
|
||||
|
||||
const signedEvent = finalizeEvent(unsignedEvent, testSecretKey)
|
||||
|
||||
expect(signedEvent.sig).toBeDefined()
|
||||
expect(signedEvent.id).toBe(getEventHash(signedEvent))
|
||||
expect(validateEvent(signedEvent)).toBe(true)
|
||||
})
|
||||
|
||||
it('should create consistent event IDs', () => {
|
||||
const event1 = {
|
||||
kind: 1,
|
||||
content: 'Same content',
|
||||
tags: [],
|
||||
created_at: 1234567890,
|
||||
pubkey: testPublicKey
|
||||
}
|
||||
|
||||
const event2 = {
|
||||
kind: 1,
|
||||
content: 'Same content',
|
||||
tags: [],
|
||||
created_at: 1234567890,
|
||||
pubkey: testPublicKey
|
||||
}
|
||||
|
||||
const hash1 = getEventHash(event1)
|
||||
const hash2 = getEventHash(event2)
|
||||
|
||||
expect(hash1).toBe(hash2)
|
||||
})
|
||||
|
||||
it('should produce different IDs for different content', () => {
|
||||
const event1 = {
|
||||
kind: 1,
|
||||
content: 'Content A',
|
||||
tags: [],
|
||||
created_at: 1234567890,
|
||||
pubkey: testPublicKey
|
||||
}
|
||||
|
||||
const event2 = {
|
||||
kind: 1,
|
||||
content: 'Content B',
|
||||
tags: [],
|
||||
created_at: 1234567890,
|
||||
pubkey: testPublicKey
|
||||
}
|
||||
|
||||
const hash1 = getEventHash(event1)
|
||||
const hash2 = getEventHash(event2)
|
||||
|
||||
expect(hash1).not.toBe(hash2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('replaceURL parsing', () => {
|
||||
it('should encode and decode nostr links correctly', async () => {
|
||||
const { nip19 } = await import('nostr-tools')
|
||||
|
||||
const npubEncoded = nip19.npubEncode(testPublicKey)
|
||||
const decodedNpub = nip19.decode(npubEncoded)
|
||||
|
||||
expect(decodedNpub.type).toBe('npub')
|
||||
expect(decodedNpub.data).toBe(testPublicKey)
|
||||
})
|
||||
|
||||
it('should generate correct replacements for template', () => {
|
||||
const raw = 'nostr:npub1l2s0q7j8gqkmp5j8fj8v9y5m6k9q8p7r5t3w2e1'
|
||||
const hex = '1l2s0q7j8gqkmp5j8fj8v9y5m6k9q8p7r5t3w2e1'
|
||||
const type = 'npub'
|
||||
|
||||
const replacements = {
|
||||
raw,
|
||||
hrp: type,
|
||||
hex,
|
||||
p_or_e: 'p',
|
||||
u_or_n: 'u',
|
||||
relay0: null,
|
||||
relay1: null,
|
||||
relay2: null
|
||||
}
|
||||
|
||||
const template = 'https://njump.me/{raw}'
|
||||
let result = template
|
||||
Object.entries(replacements).forEach(([pattern, value]) => {
|
||||
result = result.replace(new RegExp(`{ *${pattern} *}`, 'g'), value)
|
||||
})
|
||||
|
||||
expect(result).toBe(
|
||||
'https://njump.me/nostr:npub1l2s0q7j8gqkmp5j8fj8v9y5m6k9q8p7r5t3w2e1'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,113 +1,144 @@
|
||||
import browser from 'webextension-polyfill'
|
||||
import browser from "webextension-polyfill";
|
||||
|
||||
export const NO_PERMISSIONS_REQUIRED = {
|
||||
replaceURL: true
|
||||
}
|
||||
replaceURL: true,
|
||||
peekPublicKey: true,
|
||||
};
|
||||
|
||||
export const PERMISSION_NAMES = Object.fromEntries([
|
||||
['getPublicKey', 'read your public key'],
|
||||
['getRelays', 'read your list of preferred relays'],
|
||||
['signEvent', 'sign events using your private key'],
|
||||
['nip04.encrypt', 'encrypt messages to peers'],
|
||||
['nip04.decrypt', 'decrypt messages from peers']
|
||||
])
|
||||
["getPublicKey", "read your public key"],
|
||||
["signEvent", "sign events using your private key"],
|
||||
["nip04.encrypt", "encrypt messages to peers"],
|
||||
["nip04.decrypt", "decrypt messages from peers"],
|
||||
["nip44.encrypt", "encrypt messages to peers"],
|
||||
["nip44.decrypt", "decrypt messages from peers"],
|
||||
]);
|
||||
|
||||
function matchConditions(conditions, event) {
|
||||
if (conditions?.kinds) {
|
||||
if (event.kind in conditions.kinds) return true
|
||||
else return false
|
||||
if (event.kind in conditions.kinds) return true;
|
||||
else return false;
|
||||
}
|
||||
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function getPermissionStatus(host, type, event) {
|
||||
const { policies } = await browser.storage.local.get('policies')
|
||||
const { policies } = await browser.storage.local.get("policies");
|
||||
|
||||
const answers = [true, false]
|
||||
const answers = [true, false];
|
||||
for (let i = 0; i < answers.length; i++) {
|
||||
const accept = answers[i]
|
||||
const { conditions } = policies?.[host]?.[accept]?.[type] || {}
|
||||
const accept = answers[i];
|
||||
const { conditions } = policies?.[host]?.[accept]?.[type] || {};
|
||||
|
||||
if (conditions) {
|
||||
if (type === 'signEvent') {
|
||||
if (type === "signEvent") {
|
||||
if (matchConditions(conditions, event)) {
|
||||
return accept // may be true or false
|
||||
return accept; // may be true or false
|
||||
} else {
|
||||
}
|
||||
} else {
|
||||
return accept // may be true or false
|
||||
return accept; // may be true or false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function updatePermission(host, type, accept, conditions) {
|
||||
const { policies = {} } = await browser.storage.local.get('policies')
|
||||
const { policies = {} } = await browser.storage.local.get("policies");
|
||||
|
||||
// if the new conditions is "match everything", override the previous
|
||||
if (Object.keys(conditions).length === 0) {
|
||||
conditions = {}
|
||||
conditions = {};
|
||||
} else {
|
||||
// if we already had a policy for this, merge the conditions
|
||||
const existingConditions = policies[host]?.[accept]?.[type]?.conditions
|
||||
const existingConditions = policies[host]?.[accept]?.[type]?.conditions;
|
||||
if (existingConditions) {
|
||||
if (existingConditions.kinds && conditions.kinds) {
|
||||
Object.keys(existingConditions.kinds).forEach((kind) => {
|
||||
conditions.kinds[kind] = true
|
||||
})
|
||||
conditions.kinds[kind] = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if we have a reverse policy (accept / reject) that is exactly equal to this, remove it
|
||||
const other = !accept
|
||||
const reverse = policies?.[host]?.[other]?.[type]
|
||||
const other = !accept;
|
||||
const reverse = policies?.[host]?.[other]?.[type];
|
||||
if (
|
||||
reverse &&
|
||||
JSON.stringify(reverse.conditions) === JSON.stringify(conditions)
|
||||
) {
|
||||
delete policies[host][other][type]
|
||||
delete policies[host][other][type];
|
||||
}
|
||||
|
||||
// insert our new policy
|
||||
policies[host] = policies[host] || {}
|
||||
policies[host][accept] = policies[host][accept] || {}
|
||||
policies[host] = policies[host] || {};
|
||||
policies[host][accept] = policies[host][accept] || {};
|
||||
policies[host][accept][type] = {
|
||||
conditions, // filter that must match the event (in case of signEvent)
|
||||
created_at: Math.round(Date.now() / 1000)
|
||||
}
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
};
|
||||
|
||||
browser.storage.local.set({ policies })
|
||||
browser.storage.local.set({ policies });
|
||||
}
|
||||
|
||||
export async function removePermissions(host, accept, type) {
|
||||
const { policies = {} } = await browser.storage.local.get('policies')
|
||||
delete policies[host]?.[accept]?.[type]
|
||||
browser.storage.local.set({ policies })
|
||||
const { policies = {} } = await browser.storage.local.get("policies");
|
||||
delete policies[host]?.[accept]?.[type];
|
||||
browser.storage.local.set({ policies });
|
||||
}
|
||||
|
||||
export async function showNotification(host, answer, type, params) {
|
||||
const ok = await browser.storage.local.get('notifications')
|
||||
if (ok) {
|
||||
const action = answer ? 'allowed' : 'denied'
|
||||
const { notifications } = await browser.storage.local.get("notifications");
|
||||
if (notifications) {
|
||||
const action = answer ? "allowed" : "denied";
|
||||
browser.notifications.create(undefined, {
|
||||
type: 'basic',
|
||||
type: "basic",
|
||||
title: `${type} ${action} for ${host}`,
|
||||
message: JSON.stringify(
|
||||
params?.event
|
||||
? {
|
||||
kind: params.event.kind,
|
||||
content: params.event.content,
|
||||
tags: params.event.tags
|
||||
tags: params.event.tags,
|
||||
}
|
||||
: params,
|
||||
null,
|
||||
2
|
||||
),
|
||||
iconUrl: 'icons/48x48.png'
|
||||
})
|
||||
iconUrl: "icons/48x48.png",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPosition(width, height) {
|
||||
let left = 0;
|
||||
let top = 0;
|
||||
|
||||
try {
|
||||
const lastFocused = await browser.windows.getLastFocused();
|
||||
|
||||
if (
|
||||
lastFocused &&
|
||||
lastFocused.top !== undefined &&
|
||||
lastFocused.left !== undefined &&
|
||||
lastFocused.width !== undefined &&
|
||||
lastFocused.height !== undefined
|
||||
) {
|
||||
top = Math.round(lastFocused.top + (lastFocused.height - height) / 2);
|
||||
left = Math.round(lastFocused.left + (lastFocused.width - width) / 2);
|
||||
} else {
|
||||
console.error("Last focused window properties are undefined.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error getting window position:", error);
|
||||
}
|
||||
|
||||
return {
|
||||
top,
|
||||
left,
|
||||
};
|
||||
}
|
||||
|
||||
253
extension/common.test.js
Normal file
253
extension/common.test.js
Normal file
@@ -0,0 +1,253 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import './test-utils'
|
||||
import {
|
||||
NO_PERMISSIONS_REQUIRED,
|
||||
PERMISSION_NAMES,
|
||||
getPermissionStatus,
|
||||
updatePermission,
|
||||
removePermissions,
|
||||
showNotification,
|
||||
getPosition
|
||||
} from './common'
|
||||
|
||||
describe('common.js', () => {
|
||||
beforeEach(() => {
|
||||
browser.storage.local._reset()
|
||||
browser.notifications._reset()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('NO_PERMISSIONS_REQUIRED', () => {
|
||||
it('should include replaceURL without permission', () => {
|
||||
expect(NO_PERMISSIONS_REQUIRED.replaceURL).toBe(true)
|
||||
})
|
||||
|
||||
it('should include peekPublicKey without permission', () => {
|
||||
expect(NO_PERMISSIONS_REQUIRED.peekPublicKey).toBe(true)
|
||||
})
|
||||
|
||||
it('should not include getPublicKey without permission', () => {
|
||||
expect(NO_PERMISSIONS_REQUIRED.getPublicKey).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should not include signEvent without permission', () => {
|
||||
expect(NO_PERMISSIONS_REQUIRED.signEvent).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PERMISSION_NAMES', () => {
|
||||
it('should have permission descriptions for all operations', () => {
|
||||
expect(PERMISSION_NAMES.getPublicKey).toBe('read your public key')
|
||||
expect(PERMISSION_NAMES.signEvent).toBe(
|
||||
'sign events using your private key'
|
||||
)
|
||||
expect(PERMISSION_NAMES['nip04.encrypt']).toBe(
|
||||
'encrypt messages to peers'
|
||||
)
|
||||
expect(PERMISSION_NAMES['nip04.decrypt']).toBe(
|
||||
'decrypt messages from peers'
|
||||
)
|
||||
expect(PERMISSION_NAMES['nip44.encrypt']).toBe(
|
||||
'encrypt messages to peers'
|
||||
)
|
||||
expect(PERMISSION_NAMES['nip44.decrypt']).toBe(
|
||||
'decrypt messages from peers'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPermissionStatus', () => {
|
||||
it('should return undefined when no policies exist', async () => {
|
||||
const result = await getPermissionStatus('example.com', 'getPublicKey')
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return true when host has accepted permission', async () => {
|
||||
browser.storage.local.set({
|
||||
policies: {
|
||||
'example.com': {
|
||||
true: {
|
||||
getPublicKey: {
|
||||
conditions: {},
|
||||
created_at: 1234567890
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const result = await getPermissionStatus('example.com', 'getPublicKey')
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when host has rejected permission', async () => {
|
||||
browser.storage.local.set({
|
||||
policies: {
|
||||
'example.com': {
|
||||
false: {
|
||||
getPublicKey: {
|
||||
conditions: {},
|
||||
created_at: 1234567890
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const result = await getPermissionStatus('example.com', 'getPublicKey')
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true over false when both exist', async () => {
|
||||
browser.storage.local.set({
|
||||
policies: {
|
||||
'example.com': {
|
||||
true: {
|
||||
getPublicKey: { conditions: {}, created_at: 1234567890 }
|
||||
},
|
||||
false: {
|
||||
getPublicKey: { conditions: {}, created_at: 1234567890 }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const result = await getPermissionStatus('example.com', 'getPublicKey')
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should check kind conditions for signEvent', async () => {
|
||||
browser.storage.local.set({
|
||||
policies: {
|
||||
'example.com': {
|
||||
true: {
|
||||
signEvent: {
|
||||
conditions: { kinds: { 1: true, 4: true } },
|
||||
created_at: 1234567890
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const event1 = { kind: 1 }
|
||||
const event4 = { kind: 4 }
|
||||
const event7 = { kind: 7 }
|
||||
|
||||
expect(
|
||||
await getPermissionStatus('example.com', 'signEvent', event1)
|
||||
).toBe(true)
|
||||
expect(
|
||||
await getPermissionStatus('example.com', 'signEvent', event4)
|
||||
).toBe(true)
|
||||
expect(
|
||||
await getPermissionStatus('example.com', 'signEvent', event7)
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('updatePermission', () => {
|
||||
it('should create new permission', async () => {
|
||||
await updatePermission('example.com', 'getPublicKey', true, {})
|
||||
|
||||
const { policies } = await browser.storage.local.get('policies')
|
||||
expect(policies['example.com'].true.getPublicKey).toBeDefined()
|
||||
expect(policies['example.com'].true.getPublicKey.conditions).toEqual({})
|
||||
})
|
||||
|
||||
it('should update existing permission', async () => {
|
||||
await updatePermission('example.com', 'getPublicKey', true, {})
|
||||
await updatePermission('example.com', 'getPublicKey', true, {})
|
||||
|
||||
const { policies } = await browser.storage.local.get('policies')
|
||||
expect(policies['example.com'].true.getPublicKey).toBeDefined()
|
||||
})
|
||||
|
||||
it('should remove reverse policy when same conditions', async () => {
|
||||
await updatePermission('example.com', 'getPublicKey', false, {})
|
||||
await updatePermission('example.com', 'getPublicKey', true, {})
|
||||
|
||||
const { policies } = await browser.storage.local.get('policies')
|
||||
expect(policies['example.com']?.false?.getPublicKey).toBeUndefined()
|
||||
expect(policies['example.com'].true.getPublicKey).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('removePermissions', () => {
|
||||
it('should remove specific permission', async () => {
|
||||
browser.storage.local.set({
|
||||
policies: {
|
||||
'example.com': {
|
||||
true: {
|
||||
getPublicKey: { conditions: {}, created_at: 1234567890 }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await removePermissions('example.com', true, 'getPublicKey')
|
||||
|
||||
const { policies } = await browser.storage.local.get('policies')
|
||||
expect(policies['example.com'].true.getPublicKey).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('showNotification', () => {
|
||||
it('should create notification when enabled', async () => {
|
||||
browser.storage.local.set({ notifications: true })
|
||||
|
||||
await showNotification('example.com', true, 'getPublicKey', {})
|
||||
|
||||
expect(browser.notifications.create).toHaveBeenCalledWith(undefined, {
|
||||
type: 'basic',
|
||||
title: 'getPublicKey allowed for example.com',
|
||||
message: expect.any(String),
|
||||
iconUrl: 'icons/48x48.png'
|
||||
})
|
||||
})
|
||||
|
||||
it('should not create notification when disabled', async () => {
|
||||
browser.storage.local.set({ notifications: false })
|
||||
|
||||
await showNotification('example.com', true, 'getPublicKey', {})
|
||||
|
||||
expect(browser.notifications.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should format event details in message', async () => {
|
||||
browser.storage.local.set({ notifications: true })
|
||||
|
||||
await showNotification('example.com', true, 'signEvent', {
|
||||
event: { kind: 1, content: 'Hello', tags: [] }
|
||||
})
|
||||
|
||||
expect(browser.notifications.create).toHaveBeenCalledWith(undefined, {
|
||||
type: 'basic',
|
||||
title: 'signEvent allowed for example.com',
|
||||
message: expect.stringContaining('1'),
|
||||
iconUrl: 'icons/48x48.png'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPosition', () => {
|
||||
it('should return centered position', async () => {
|
||||
const position = await getPosition(440, 420)
|
||||
|
||||
expect(position.top).toBe(430) // Math.round(100 + (1080 - 420) / 2)
|
||||
expect(position.left).toBe(840) // Math.round(100 + (1920 - 440) / 2)
|
||||
})
|
||||
|
||||
it('should handle window without position data', async () => {
|
||||
browser.windows.getLastFocused.mockResolvedValueOnce({
|
||||
top: undefined,
|
||||
left: undefined
|
||||
})
|
||||
|
||||
const position = await getPosition(440, 420)
|
||||
|
||||
expect(position.top).toBe(0)
|
||||
expect(position.left).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,36 +1,36 @@
|
||||
import browser from 'webextension-polyfill'
|
||||
import browser from "webextension-polyfill";
|
||||
|
||||
const EXTENSION = 'nostrconnect'
|
||||
const EXTENSION = "nostrconnect";
|
||||
|
||||
// inject the script that will provide window.nostr
|
||||
const script = document.createElement('script')
|
||||
script.setAttribute('async', 'false')
|
||||
script.setAttribute('type', 'text/javascript')
|
||||
script.setAttribute('src', browser.runtime.getURL('nostr-provider.js'))
|
||||
document.head.appendChild(script)
|
||||
const script = document.createElement("script");
|
||||
script.setAttribute("async", "false");
|
||||
script.setAttribute("type", "text/javascript");
|
||||
script.setAttribute("src", browser.runtime.getURL("nostr-provider.js"));
|
||||
document.head.appendChild(script);
|
||||
|
||||
// listen for messages from that script
|
||||
window.addEventListener('message', async (message) => {
|
||||
if (message.source !== window) return
|
||||
if (!message.data) return
|
||||
if (!message.data.params) return
|
||||
if (message.data.ext !== EXTENSION) return
|
||||
window.addEventListener("message", async (message) => {
|
||||
if (message.source !== window) return;
|
||||
if (!message.data) return;
|
||||
if (!message.data.params) return;
|
||||
if (message.data.ext !== EXTENSION) return;
|
||||
|
||||
// pass on to background
|
||||
var response
|
||||
var response;
|
||||
try {
|
||||
response = await browser.runtime.sendMessage({
|
||||
type: message.data.type,
|
||||
params: message.data.params,
|
||||
host: location.host
|
||||
})
|
||||
host: location.host,
|
||||
});
|
||||
} catch (error) {
|
||||
response = { error }
|
||||
response = { error };
|
||||
}
|
||||
|
||||
// return response
|
||||
window.postMessage(
|
||||
{ id: message.data.id, ext: EXTENSION, response },
|
||||
message.origin
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -10,12 +10,16 @@ window.nostr = {
|
||||
return this._pubkey
|
||||
},
|
||||
|
||||
async peekPublicKey() {
|
||||
return this._call('peekPublicKey', {})
|
||||
},
|
||||
|
||||
async signEvent(event) {
|
||||
return this._call('signEvent', { event })
|
||||
},
|
||||
|
||||
async getRelays() {
|
||||
return this._call('getRelays', {})
|
||||
return {}
|
||||
},
|
||||
|
||||
nip04: {
|
||||
@@ -28,6 +32,16 @@ window.nostr = {
|
||||
}
|
||||
},
|
||||
|
||||
nip44: {
|
||||
async encrypt(peer, plaintext) {
|
||||
return window.nostr._call('nip44.encrypt', { peer, plaintext })
|
||||
},
|
||||
|
||||
async decrypt(peer, ciphertext) {
|
||||
return window.nostr._call('nip44.decrypt', { peer, ciphertext })
|
||||
}
|
||||
},
|
||||
|
||||
_call(type, params) {
|
||||
const id = Math.random().toString().slice(-4)
|
||||
console.log(
|
||||
|
||||
101
extension/nostr-provider.test.js
Normal file
101
extension/nostr-provider.test.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
describe('nostr-provider.js structure', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('provider API structure', () => {
|
||||
it('should have all required NIP-07 methods', () => {
|
||||
const requiredMethods = [
|
||||
'getPublicKey',
|
||||
'signEvent',
|
||||
'getRelays',
|
||||
'nip04',
|
||||
'nip44'
|
||||
]
|
||||
|
||||
requiredMethods.forEach((method) => {
|
||||
expect(typeof method).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
it('should define EXTENSION constant', () => {
|
||||
const EXTENSION = 'nostrconnect'
|
||||
expect(EXTENSION).toBe('nostrconnect')
|
||||
})
|
||||
|
||||
it('should have _requests object for tracking pending calls', () => {
|
||||
const _requests = {}
|
||||
expect(typeof _requests).toBe('object')
|
||||
})
|
||||
|
||||
it('should have _pubkey for caching public key', () => {
|
||||
const _pubkey = null
|
||||
expect(_pubkey).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('nip04 namespace', () => {
|
||||
it('should have encrypt and decrypt methods', () => {
|
||||
const nip04 = {
|
||||
encrypt: (peer, plaintext) => {},
|
||||
decrypt: (peer, ciphertext) => {}
|
||||
}
|
||||
|
||||
expect(typeof nip04.encrypt).toBe('function')
|
||||
expect(typeof nip04.decrypt).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('nip44 namespace', () => {
|
||||
it('should have encrypt and decrypt methods', () => {
|
||||
const nip44 = {
|
||||
encrypt: (peer, plaintext) => {},
|
||||
decrypt: (peer, ciphertext) => {}
|
||||
}
|
||||
|
||||
expect(typeof nip44.encrypt).toBe('function')
|
||||
expect(typeof nip44.decrypt).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('message protocol', () => {
|
||||
it('should send messages with correct structure', () => {
|
||||
const message = {
|
||||
id: '1234',
|
||||
ext: 'nostrconnect',
|
||||
type: 'getPublicKey',
|
||||
params: {}
|
||||
}
|
||||
|
||||
expect(message.ext).toBe('nostrconnect')
|
||||
expect(message.id).toBeDefined()
|
||||
expect(message.type).toBeDefined()
|
||||
})
|
||||
|
||||
it('should handle _call with unique IDs', () => {
|
||||
const generateId = () => Math.random().toString().slice(-4)
|
||||
const id1 = generateId()
|
||||
const id2 = generateId()
|
||||
|
||||
expect(typeof id1).toBe('string')
|
||||
expect(typeof id2).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRelays implementation', () => {
|
||||
it('should return empty object synchronously', () => {
|
||||
const getRelays = () => ({})
|
||||
const relays = getRelays()
|
||||
expect(relays).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('peekPublicKey', () => {
|
||||
it('should be available as a method', () => {
|
||||
const peekPublicKey = () => {}
|
||||
expect(typeof peekPublicKey).toBe('function')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1127,10 +1127,10 @@
|
||||
sum += a.length;
|
||||
}
|
||||
const res = new Uint8Array(sum);
|
||||
for (let i2 = 0, pad2 = 0; i2 < arrays.length; i2++) {
|
||||
for (let i2 = 0, pad3 = 0; i2 < arrays.length; i2++) {
|
||||
const a = arrays[i2];
|
||||
res.set(a, pad2);
|
||||
pad2 += a.length;
|
||||
res.set(a, pad3);
|
||||
pad3 += a.length;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
@@ -2163,16 +2163,16 @@
|
||||
this.blockLen = this.iHash.blockLen;
|
||||
this.outputLen = this.iHash.outputLen;
|
||||
const blockLen = this.blockLen;
|
||||
const pad2 = new Uint8Array(blockLen);
|
||||
pad2.set(key.length > blockLen ? hash.create().update(key).digest() : key);
|
||||
for (let i2 = 0; i2 < pad2.length; i2++)
|
||||
pad2[i2] ^= 54;
|
||||
this.iHash.update(pad2);
|
||||
const pad3 = new Uint8Array(blockLen);
|
||||
pad3.set(key.length > blockLen ? hash.create().update(key).digest() : key);
|
||||
for (let i2 = 0; i2 < pad3.length; i2++)
|
||||
pad3[i2] ^= 54;
|
||||
this.iHash.update(pad3);
|
||||
this.oHash = hash.create();
|
||||
for (let i2 = 0; i2 < pad2.length; i2++)
|
||||
pad2[i2] ^= 54 ^ 92;
|
||||
this.oHash.update(pad2);
|
||||
clean(pad2);
|
||||
for (let i2 = 0; i2 < pad3.length; i2++)
|
||||
pad3[i2] ^= 54 ^ 92;
|
||||
this.oHash.update(pad3);
|
||||
clean(pad3);
|
||||
}
|
||||
update(buf) {
|
||||
aexists(this);
|
||||
@@ -2785,7 +2785,7 @@
|
||||
const l = abytes(item, void 0, "key").length;
|
||||
return l === publicKey || l === publicKeyUncompressed;
|
||||
}
|
||||
function getSharedSecret(secretKeyA, publicKeyB, isCompressed = true) {
|
||||
function getSharedSecret2(secretKeyA, publicKeyB, isCompressed = true) {
|
||||
if (isProbPub(secretKeyA) === true)
|
||||
throw new Error("first arg must be private key");
|
||||
if (isProbPub(publicKeyB) === false)
|
||||
@@ -2800,7 +2800,7 @@
|
||||
randomSecretKey
|
||||
};
|
||||
const keygen = createKeygen(randomSecretKey, getPublicKey2);
|
||||
return Object.freeze({ getPublicKey: getPublicKey2, getSharedSecret, keygen, Point, utils, lengths });
|
||||
return Object.freeze({ getPublicKey: getPublicKey2, getSharedSecret: getSharedSecret2, keygen, Point, utils, lengths });
|
||||
}
|
||||
function ecdsa(Point, hash, ecdsaOpts = {}) {
|
||||
ahash(hash);
|
||||
@@ -2816,7 +2816,7 @@
|
||||
const hmac2 = ecdsaOpts.hmac || ((key, msg) => hmac(hash, key, msg));
|
||||
const { Fp, Fn } = Point;
|
||||
const { ORDER: CURVE_ORDER, BITS: fnBits } = Fn;
|
||||
const { keygen, getPublicKey: getPublicKey2, getSharedSecret, utils, lengths } = ecdh(Point, ecdsaOpts);
|
||||
const { keygen, getPublicKey: getPublicKey2, getSharedSecret: getSharedSecret2, utils, lengths } = ecdh(Point, ecdsaOpts);
|
||||
const defaultSigOpts = {
|
||||
prehash: true,
|
||||
lowS: typeof ecdsaOpts.lowS === "boolean" ? ecdsaOpts.lowS : true,
|
||||
@@ -3022,7 +3022,7 @@
|
||||
return Object.freeze({
|
||||
keygen,
|
||||
getPublicKey: getPublicKey2,
|
||||
getSharedSecret,
|
||||
getSharedSecret: getSharedSecret2,
|
||||
utils,
|
||||
lengths,
|
||||
Point,
|
||||
@@ -4339,7 +4339,7 @@
|
||||
h[9] = d9;
|
||||
}
|
||||
finalize() {
|
||||
const { h, pad: pad2 } = this;
|
||||
const { h, pad: pad3 } = this;
|
||||
const g = new Uint16Array(10);
|
||||
let c = h[1] >>> 13;
|
||||
h[1] &= 8191;
|
||||
@@ -4378,10 +4378,10 @@
|
||||
h[5] = (h[6] >>> 2 | h[7] << 11) & 65535;
|
||||
h[6] = (h[7] >>> 5 | h[8] << 8) & 65535;
|
||||
h[7] = (h[8] >>> 8 | h[9] << 5) & 65535;
|
||||
let f = h[0] + pad2[0];
|
||||
let f = h[0] + pad3[0];
|
||||
h[0] = f & 65535;
|
||||
for (let i2 = 1; i2 < 8; i2++) {
|
||||
f = (h[i2] + pad2[i2] | 0) + (f >>> 16) | 0;
|
||||
f = (h[i2] + pad3[i2] | 0) + (f >>> 16) | 0;
|
||||
h[i2] = f & 65535;
|
||||
}
|
||||
clean2(g);
|
||||
@@ -7122,6 +7122,110 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
// node_modules/.pnpm/nostr-tools@2.23.3/node_modules/nostr-tools/lib/esm/nip44.js
|
||||
var utf8Decoder2 = new TextDecoder("utf-8");
|
||||
var utf8Encoder2 = new TextEncoder();
|
||||
var minPlaintextSize2 = 1;
|
||||
var maxPlaintextSize2 = 65535;
|
||||
function getConversationKey2(privkeyA, pubkeyB) {
|
||||
const sharedX = secp256k1.getSharedSecret(privkeyA, hexToBytes("02" + pubkeyB)).subarray(1, 33);
|
||||
return extract(sha256, sharedX, utf8Encoder2.encode("nip44-v2"));
|
||||
}
|
||||
function getMessageKeys2(conversationKey, nonce) {
|
||||
const keys = 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 calcPaddedLen2(len) {
|
||||
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 writeU16BE2(num2) {
|
||||
if (!Number.isSafeInteger(num2) || num2 < minPlaintextSize2 || num2 > maxPlaintextSize2)
|
||||
throw new Error("invalid plaintext size: must be between 1 and 65535 bytes");
|
||||
const arr = new Uint8Array(2);
|
||||
new DataView(arr.buffer).setUint16(0, num2, false);
|
||||
return arr;
|
||||
}
|
||||
function pad2(plaintext) {
|
||||
const unpadded = utf8Encoder2.encode(plaintext);
|
||||
const unpaddedLen = unpadded.length;
|
||||
const prefix = writeU16BE2(unpaddedLen);
|
||||
const suffix = new Uint8Array(calcPaddedLen2(unpaddedLen) - unpaddedLen);
|
||||
return concatBytes(prefix, unpadded, suffix);
|
||||
}
|
||||
function unpad2(padded) {
|
||||
const unpaddedLen = new DataView(padded.buffer).getUint16(0);
|
||||
const unpadded = padded.subarray(2, 2 + unpaddedLen);
|
||||
if (unpaddedLen < minPlaintextSize2 || unpaddedLen > maxPlaintextSize2 || unpadded.length !== unpaddedLen || padded.length !== 2 + calcPaddedLen2(unpaddedLen))
|
||||
throw new Error("invalid padding");
|
||||
return utf8Decoder2.decode(unpadded);
|
||||
}
|
||||
function hmacAad2(key, message, aad) {
|
||||
if (aad.length !== 32)
|
||||
throw new Error("AAD associated data must be 32 bytes");
|
||||
const combined = concatBytes(aad, message);
|
||||
return hmac(sha256, key, combined);
|
||||
}
|
||||
function decodePayload2(payload) {
|
||||
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;
|
||||
try {
|
||||
data = base64.decode(payload);
|
||||
} catch (error) {
|
||||
throw new Error("invalid base64: " + error.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)
|
||||
};
|
||||
}
|
||||
function encrypt3(plaintext, conversationKey, nonce = randomBytes(32)) {
|
||||
const { chacha_key, chacha_nonce, hmac_key } = getMessageKeys2(conversationKey, nonce);
|
||||
const padded = pad2(plaintext);
|
||||
const ciphertext = chacha20(chacha_key, chacha_nonce, padded);
|
||||
const mac = hmacAad2(hmac_key, ciphertext, nonce);
|
||||
return base64.encode(concatBytes(new Uint8Array([2]), nonce, ciphertext, mac));
|
||||
}
|
||||
function decrypt3(payload, conversationKey) {
|
||||
const { nonce, ciphertext, mac } = decodePayload2(payload);
|
||||
const { chacha_key, chacha_nonce, hmac_key } = getMessageKeys2(conversationKey, nonce);
|
||||
const calculatedMac = hmacAad2(hmac_key, ciphertext, nonce);
|
||||
if (!equalBytes(calculatedMac, mac))
|
||||
throw new Error("invalid MAC");
|
||||
const padded = chacha20(chacha_key, chacha_nonce, ciphertext);
|
||||
return unpad2(padded);
|
||||
}
|
||||
var v22 = {
|
||||
utils: {
|
||||
getConversationKey: getConversationKey2,
|
||||
calcPaddedLen: calcPaddedLen2
|
||||
},
|
||||
encrypt: encrypt3,
|
||||
decrypt: decrypt3
|
||||
};
|
||||
|
||||
// node_modules/.pnpm/async-mutex@0.3.2/node_modules/async-mutex/index.mjs
|
||||
var E_TIMEOUT = new Error("timeout while waiting for mutex to become available");
|
||||
var E_ALREADY_LOCKED = new Error("mutex already locked");
|
||||
@@ -7281,17 +7385,51 @@
|
||||
}
|
||||
};
|
||||
|
||||
// extension/utils.js
|
||||
var LRUCache = class {
|
||||
constructor(maxSize) {
|
||||
this.maxSize = maxSize;
|
||||
this.map = /* @__PURE__ */ new Map();
|
||||
this.keys = [];
|
||||
}
|
||||
clear() {
|
||||
this.map.clear();
|
||||
}
|
||||
has(k) {
|
||||
return this.map.has(k);
|
||||
}
|
||||
get(k) {
|
||||
const v = this.map.get(k);
|
||||
if (v !== void 0) {
|
||||
this.keys.push(k);
|
||||
if (this.keys.length > this.maxSize * 2) {
|
||||
this.keys.splice(-this.maxSize);
|
||||
}
|
||||
}
|
||||
return v;
|
||||
}
|
||||
set(k, v) {
|
||||
this.map.set(k, v);
|
||||
this.keys.push(k);
|
||||
if (this.map.size > this.maxSize) {
|
||||
this.map.delete(this.keys.shift());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// extension/common.js
|
||||
var import_webextension_polyfill = __toESM(require_browser_polyfill());
|
||||
var NO_PERMISSIONS_REQUIRED = {
|
||||
replaceURL: true
|
||||
replaceURL: true,
|
||||
peekPublicKey: true
|
||||
};
|
||||
var PERMISSION_NAMES = Object.fromEntries([
|
||||
["getPublicKey", "read your public key"],
|
||||
["getRelays", "read your list of preferred relays"],
|
||||
["signEvent", "sign events using your private key"],
|
||||
["nip04.encrypt", "encrypt messages to peers"],
|
||||
["nip04.decrypt", "decrypt messages from peers"]
|
||||
["nip04.decrypt", "decrypt messages from peers"],
|
||||
["nip44.encrypt", "encrypt messages to peers"],
|
||||
["nip44.decrypt", "decrypt messages from peers"]
|
||||
]);
|
||||
function matchConditions(conditions, event) {
|
||||
if (conditions?.kinds) {
|
||||
@@ -7349,8 +7487,8 @@
|
||||
import_webextension_polyfill.default.storage.local.set({ policies });
|
||||
}
|
||||
async function showNotification(host, answer, type, params) {
|
||||
const ok = await import_webextension_polyfill.default.storage.local.get("notifications");
|
||||
if (ok) {
|
||||
const { notifications } = await import_webextension_polyfill.default.storage.local.get("notifications");
|
||||
if (notifications) {
|
||||
const action = answer ? "allowed" : "denied";
|
||||
import_webextension_polyfill.default.notifications.create(void 0, {
|
||||
type: "basic",
|
||||
@@ -7368,14 +7506,48 @@
|
||||
});
|
||||
}
|
||||
}
|
||||
async function getPosition(width2, height2) {
|
||||
let left = 0;
|
||||
let top = 0;
|
||||
try {
|
||||
const lastFocused = await import_webextension_polyfill.default.windows.getLastFocused();
|
||||
if (lastFocused && lastFocused.top !== void 0 && lastFocused.left !== void 0 && lastFocused.width !== void 0 && lastFocused.height !== void 0) {
|
||||
top = Math.round(lastFocused.top + (lastFocused.height - height2) / 2);
|
||||
left = Math.round(lastFocused.left + (lastFocused.width - width2) / 2);
|
||||
} else {
|
||||
console.error("Last focused window properties are undefined.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error getting window position:", error);
|
||||
}
|
||||
return {
|
||||
top,
|
||||
left
|
||||
};
|
||||
}
|
||||
|
||||
// extension/background.js
|
||||
var { hexToBytes: hexToBytes2 } = utils_exports;
|
||||
var { encrypt: encrypt3, decrypt: decrypt3 } = nip04_exports;
|
||||
var { encrypt: encrypt4, decrypt: decrypt4 } = nip04_exports;
|
||||
var openPrompt = null;
|
||||
var promptMutex = new Mutex();
|
||||
var releasePromptMutex = () => {
|
||||
};
|
||||
var secretsCache = new LRUCache(100);
|
||||
var previousSk = null;
|
||||
function getSharedSecret(sk, peer) {
|
||||
if (previousSk !== sk) {
|
||||
secretsCache.clear();
|
||||
}
|
||||
let key = secretsCache.get(peer);
|
||||
if (!key) {
|
||||
key = v22.utils.getConversationKey(sk, peer);
|
||||
secretsCache.set(peer, key);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
var width = 440;
|
||||
var height = 420;
|
||||
import_webextension_polyfill2.default.runtime.onInstalled.addListener((_, __, reason) => {
|
||||
if (reason === "install")
|
||||
import_webextension_polyfill2.default.runtime.openOptionsPage();
|
||||
@@ -7402,6 +7574,12 @@
|
||||
async function handleContentScriptMessage({ type, params, host }) {
|
||||
if (NO_PERMISSIONS_REQUIRED[type]) {
|
||||
switch (type) {
|
||||
case "peekPublicKey": {
|
||||
const allowed = await getPermissionStatus(host, "getPublicKey");
|
||||
if (allowed === true)
|
||||
return performOperation("getPublicKey", params);
|
||||
return "";
|
||||
}
|
||||
case "replaceURL": {
|
||||
const { protocol_handler: ph } = await import_webextension_polyfill2.default.storage.local.get([
|
||||
"protocol_handler"
|
||||
@@ -7459,12 +7637,15 @@
|
||||
const url = `${import_webextension_polyfill2.default.runtime.getURL(
|
||||
"prompt.html"
|
||||
)}?${qs.toString()}`;
|
||||
const { top, left } = getPosition(width, height);
|
||||
if (import_webextension_polyfill2.default.windows) {
|
||||
import_webextension_polyfill2.default.windows.create({
|
||||
url,
|
||||
type: "popup",
|
||||
width: 600,
|
||||
height: 600
|
||||
width,
|
||||
height,
|
||||
top,
|
||||
left
|
||||
});
|
||||
} else {
|
||||
import_webextension_polyfill2.default.tabs.create({
|
||||
@@ -7474,11 +7655,11 @@
|
||||
}
|
||||
});
|
||||
if (!accept)
|
||||
return { error: "denied" };
|
||||
return { error: { message: "denied" } };
|
||||
} catch (err) {
|
||||
releasePromptMutex();
|
||||
return {
|
||||
error: `error: ${err}`
|
||||
error: { message: err.message, stack: err.stack }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -7510,11 +7691,21 @@
|
||||
}
|
||||
case "nip04.encrypt": {
|
||||
const { peer, plaintext } = params;
|
||||
return encrypt3(sk, peer, plaintext);
|
||||
return encrypt4(sk, peer, plaintext);
|
||||
}
|
||||
case "nip04.decrypt": {
|
||||
const { peer, ciphertext } = params;
|
||||
return decrypt3(sk, peer, ciphertext);
|
||||
return decrypt4(sk, peer, ciphertext);
|
||||
}
|
||||
case "nip44.encrypt": {
|
||||
const { peer, plaintext } = params;
|
||||
const key = getSharedSecret(sk, peer);
|
||||
return v22.encrypt(plaintext, key);
|
||||
}
|
||||
case "nip44.decrypt": {
|
||||
const { peer, ciphertext } = params;
|
||||
const key = getSharedSecret(sk, peer);
|
||||
return v22.decrypt(ciphertext, key);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,113 +1,144 @@
|
||||
import browser from 'webextension-polyfill'
|
||||
import browser from "webextension-polyfill";
|
||||
|
||||
export const NO_PERMISSIONS_REQUIRED = {
|
||||
replaceURL: true
|
||||
}
|
||||
replaceURL: true,
|
||||
peekPublicKey: true,
|
||||
};
|
||||
|
||||
export const PERMISSION_NAMES = Object.fromEntries([
|
||||
['getPublicKey', 'read your public key'],
|
||||
['getRelays', 'read your list of preferred relays'],
|
||||
['signEvent', 'sign events using your private key'],
|
||||
['nip04.encrypt', 'encrypt messages to peers'],
|
||||
['nip04.decrypt', 'decrypt messages from peers']
|
||||
])
|
||||
["getPublicKey", "read your public key"],
|
||||
["signEvent", "sign events using your private key"],
|
||||
["nip04.encrypt", "encrypt messages to peers"],
|
||||
["nip04.decrypt", "decrypt messages from peers"],
|
||||
["nip44.encrypt", "encrypt messages to peers"],
|
||||
["nip44.decrypt", "decrypt messages from peers"],
|
||||
]);
|
||||
|
||||
function matchConditions(conditions, event) {
|
||||
if (conditions?.kinds) {
|
||||
if (event.kind in conditions.kinds) return true
|
||||
else return false
|
||||
if (event.kind in conditions.kinds) return true;
|
||||
else return false;
|
||||
}
|
||||
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function getPermissionStatus(host, type, event) {
|
||||
const { policies } = await browser.storage.local.get('policies')
|
||||
const { policies } = await browser.storage.local.get("policies");
|
||||
|
||||
const answers = [true, false]
|
||||
const answers = [true, false];
|
||||
for (let i = 0; i < answers.length; i++) {
|
||||
const accept = answers[i]
|
||||
const { conditions } = policies?.[host]?.[accept]?.[type] || {}
|
||||
const accept = answers[i];
|
||||
const { conditions } = policies?.[host]?.[accept]?.[type] || {};
|
||||
|
||||
if (conditions) {
|
||||
if (type === 'signEvent') {
|
||||
if (type === "signEvent") {
|
||||
if (matchConditions(conditions, event)) {
|
||||
return accept // may be true or false
|
||||
return accept; // may be true or false
|
||||
} else {
|
||||
}
|
||||
} else {
|
||||
return accept // may be true or false
|
||||
return accept; // may be true or false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function updatePermission(host, type, accept, conditions) {
|
||||
const { policies = {} } = await browser.storage.local.get('policies')
|
||||
const { policies = {} } = await browser.storage.local.get("policies");
|
||||
|
||||
// if the new conditions is "match everything", override the previous
|
||||
if (Object.keys(conditions).length === 0) {
|
||||
conditions = {}
|
||||
conditions = {};
|
||||
} else {
|
||||
// if we already had a policy for this, merge the conditions
|
||||
const existingConditions = policies[host]?.[accept]?.[type]?.conditions
|
||||
const existingConditions = policies[host]?.[accept]?.[type]?.conditions;
|
||||
if (existingConditions) {
|
||||
if (existingConditions.kinds && conditions.kinds) {
|
||||
Object.keys(existingConditions.kinds).forEach((kind) => {
|
||||
conditions.kinds[kind] = true
|
||||
})
|
||||
conditions.kinds[kind] = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if we have a reverse policy (accept / reject) that is exactly equal to this, remove it
|
||||
const other = !accept
|
||||
const reverse = policies?.[host]?.[other]?.[type]
|
||||
const other = !accept;
|
||||
const reverse = policies?.[host]?.[other]?.[type];
|
||||
if (
|
||||
reverse &&
|
||||
JSON.stringify(reverse.conditions) === JSON.stringify(conditions)
|
||||
) {
|
||||
delete policies[host][other][type]
|
||||
delete policies[host][other][type];
|
||||
}
|
||||
|
||||
// insert our new policy
|
||||
policies[host] = policies[host] || {}
|
||||
policies[host][accept] = policies[host][accept] || {}
|
||||
policies[host] = policies[host] || {};
|
||||
policies[host][accept] = policies[host][accept] || {};
|
||||
policies[host][accept][type] = {
|
||||
conditions, // filter that must match the event (in case of signEvent)
|
||||
created_at: Math.round(Date.now() / 1000)
|
||||
}
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
};
|
||||
|
||||
browser.storage.local.set({ policies })
|
||||
browser.storage.local.set({ policies });
|
||||
}
|
||||
|
||||
export async function removePermissions(host, accept, type) {
|
||||
const { policies = {} } = await browser.storage.local.get('policies')
|
||||
delete policies[host]?.[accept]?.[type]
|
||||
browser.storage.local.set({ policies })
|
||||
const { policies = {} } = await browser.storage.local.get("policies");
|
||||
delete policies[host]?.[accept]?.[type];
|
||||
browser.storage.local.set({ policies });
|
||||
}
|
||||
|
||||
export async function showNotification(host, answer, type, params) {
|
||||
const ok = await browser.storage.local.get('notifications')
|
||||
if (ok) {
|
||||
const action = answer ? 'allowed' : 'denied'
|
||||
const { notifications } = await browser.storage.local.get("notifications");
|
||||
if (notifications) {
|
||||
const action = answer ? "allowed" : "denied";
|
||||
browser.notifications.create(undefined, {
|
||||
type: 'basic',
|
||||
type: "basic",
|
||||
title: `${type} ${action} for ${host}`,
|
||||
message: JSON.stringify(
|
||||
params?.event
|
||||
? {
|
||||
kind: params.event.kind,
|
||||
content: params.event.content,
|
||||
tags: params.event.tags
|
||||
tags: params.event.tags,
|
||||
}
|
||||
: params,
|
||||
null,
|
||||
2
|
||||
),
|
||||
iconUrl: 'icons/48x48.png'
|
||||
})
|
||||
iconUrl: "icons/48x48.png",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPosition(width, height) {
|
||||
let left = 0;
|
||||
let top = 0;
|
||||
|
||||
try {
|
||||
const lastFocused = await browser.windows.getLastFocused();
|
||||
|
||||
if (
|
||||
lastFocused &&
|
||||
lastFocused.top !== undefined &&
|
||||
lastFocused.left !== undefined &&
|
||||
lastFocused.width !== undefined &&
|
||||
lastFocused.height !== undefined
|
||||
) {
|
||||
top = Math.round(lastFocused.top + (lastFocused.height - height) / 2);
|
||||
left = Math.round(lastFocused.left + (lastFocused.width - width) / 2);
|
||||
} else {
|
||||
console.error("Last focused window properties are undefined.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error getting window position:", error);
|
||||
}
|
||||
|
||||
return {
|
||||
top,
|
||||
left,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,12 +10,16 @@ window.nostr = {
|
||||
return this._pubkey
|
||||
},
|
||||
|
||||
async peekPublicKey() {
|
||||
return this._call('peekPublicKey', {})
|
||||
},
|
||||
|
||||
async signEvent(event) {
|
||||
return this._call('signEvent', { event })
|
||||
},
|
||||
|
||||
async getRelays() {
|
||||
return this._call('getRelays', {})
|
||||
return {}
|
||||
},
|
||||
|
||||
nip04: {
|
||||
@@ -28,6 +32,16 @@ window.nostr = {
|
||||
}
|
||||
},
|
||||
|
||||
nip44: {
|
||||
async encrypt(peer, plaintext) {
|
||||
return window.nostr._call('nip44.encrypt', { peer, plaintext })
|
||||
},
|
||||
|
||||
async decrypt(peer, ciphertext) {
|
||||
return window.nostr._call('nip44.decrypt', { peer, ciphertext })
|
||||
}
|
||||
},
|
||||
|
||||
_call(type, params) {
|
||||
const id = Math.random().toString().slice(-4)
|
||||
console.log(
|
||||
|
||||
@@ -31386,10 +31386,11 @@ For more info, visit https://reactjs.org/link/mock-scheduler`);
|
||||
var import_webextension_polyfill = __toESM(require_browser_polyfill());
|
||||
var PERMISSION_NAMES = Object.fromEntries([
|
||||
["getPublicKey", "read your public key"],
|
||||
["getRelays", "read your list of preferred relays"],
|
||||
["signEvent", "sign events using your private key"],
|
||||
["nip04.encrypt", "encrypt messages to peers"],
|
||||
["nip04.decrypt", "decrypt messages from peers"]
|
||||
["nip04.decrypt", "decrypt messages from peers"],
|
||||
["nip44.encrypt", "encrypt messages to peers"],
|
||||
["nip44.decrypt", "decrypt messages from peers"]
|
||||
]);
|
||||
async function removePermissions(host, accept, type) {
|
||||
const { policies = {} } = await import_webextension_polyfill.default.storage.local.get("policies");
|
||||
|
||||
@@ -22489,10 +22489,11 @@ For more info, visit https://reactjs.org/link/mock-scheduler`);
|
||||
var import_webextension_polyfill = __toESM(require_browser_polyfill());
|
||||
var PERMISSION_NAMES = Object.fromEntries([
|
||||
["getPublicKey", "read your public key"],
|
||||
["getRelays", "read your list of preferred relays"],
|
||||
["signEvent", "sign events using your private key"],
|
||||
["nip04.encrypt", "encrypt messages to peers"],
|
||||
["nip04.decrypt", "decrypt messages from peers"]
|
||||
["nip04.decrypt", "decrypt messages from peers"],
|
||||
["nip44.encrypt", "encrypt messages to peers"],
|
||||
["nip44.decrypt", "decrypt messages from peers"]
|
||||
]);
|
||||
|
||||
// extension/icons.jsx
|
||||
|
||||
86
extension/test-utils.js
Normal file
86
extension/test-utils.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
const storage = {}
|
||||
let notificationId = 0
|
||||
let notifications = []
|
||||
|
||||
export const mockBrowser = {
|
||||
storage: {
|
||||
local: {
|
||||
get: vi.fn((keys) => {
|
||||
if (typeof keys === 'string') {
|
||||
return Promise.resolve({ [keys]: storage[keys] })
|
||||
}
|
||||
if (Array.isArray(keys)) {
|
||||
const result = {}
|
||||
keys.forEach((key) => {
|
||||
result[key] = storage[key]
|
||||
})
|
||||
return Promise.resolve(result)
|
||||
}
|
||||
if (keys && typeof keys === 'object') {
|
||||
const result = {}
|
||||
Object.keys(keys).forEach((key) => {
|
||||
result[key] = storage[key] !== undefined ? storage[key] : keys[key]
|
||||
})
|
||||
return Promise.resolve(result)
|
||||
}
|
||||
return Promise.resolve({})
|
||||
}),
|
||||
set: vi.fn((obj) => {
|
||||
Object.assign(storage, obj)
|
||||
return Promise.resolve()
|
||||
}),
|
||||
remove: vi.fn((keys) => {
|
||||
if (Array.isArray(keys)) {
|
||||
keys.forEach((key) => delete storage[key])
|
||||
} else {
|
||||
delete storage[keys]
|
||||
}
|
||||
return Promise.resolve()
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
Object.keys(storage).forEach((key) => delete storage[key])
|
||||
return Promise.resolve()
|
||||
}),
|
||||
_reset: () => {
|
||||
Object.keys(storage).forEach((key) => delete storage[key])
|
||||
}
|
||||
}
|
||||
},
|
||||
notifications: {
|
||||
create: vi.fn((id, options) => {
|
||||
notifications.push({ id, options })
|
||||
return `notification-${++notificationId}`
|
||||
}),
|
||||
_notifications: notifications,
|
||||
_reset: () => {
|
||||
notifications.length = 0
|
||||
notificationId = 0
|
||||
}
|
||||
},
|
||||
windows: {
|
||||
getLastFocused: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
top: 100,
|
||||
left: 100,
|
||||
width: 1920,
|
||||
height: 1080
|
||||
})
|
||||
),
|
||||
create: vi.fn(() => Promise.resolve({ id: 123 })),
|
||||
remove: vi.fn(() => Promise.resolve()),
|
||||
get: vi.fn(() => Promise.resolve({ id: 123, top: 100, left: 100 }))
|
||||
},
|
||||
tabs: {
|
||||
create: vi.fn(() => Promise.resolve({ id: 456 })),
|
||||
remove: vi.fn(() => Promise.resolve())
|
||||
},
|
||||
runtime: {
|
||||
getURL: vi.fn((path) => `chrome-extension://abc123/${path}`)
|
||||
}
|
||||
}
|
||||
|
||||
global.browser = mockBrowser
|
||||
|
||||
export { storage }
|
||||
38
extension/utils.js
Normal file
38
extension/utils.js
Normal file
@@ -0,0 +1,38 @@
|
||||
export class LRUCache {
|
||||
constructor(maxSize) {
|
||||
this.maxSize = maxSize;
|
||||
this.map = new Map();
|
||||
this.keys = [];
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.map.clear();
|
||||
}
|
||||
|
||||
has(k) {
|
||||
return this.map.has(k);
|
||||
}
|
||||
|
||||
get(k) {
|
||||
const v = this.map.get(k);
|
||||
|
||||
if (v !== undefined) {
|
||||
this.keys.push(k);
|
||||
|
||||
if (this.keys.length > this.maxSize * 2) {
|
||||
this.keys.splice(-this.maxSize);
|
||||
}
|
||||
}
|
||||
|
||||
return v;
|
||||
}
|
||||
|
||||
set(k, v) {
|
||||
this.map.set(k, v);
|
||||
this.keys.push(k);
|
||||
|
||||
if (this.map.size > this.maxSize) {
|
||||
this.map.delete(this.keys.shift());
|
||||
}
|
||||
}
|
||||
}
|
||||
78
extension/utils.test.js
Normal file
78
extension/utils.test.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { LRUCache } from './utils'
|
||||
|
||||
describe('LRUCache', () => {
|
||||
let cache
|
||||
|
||||
beforeEach(() => {
|
||||
cache = new LRUCache(3)
|
||||
})
|
||||
|
||||
describe('basic operations', () => {
|
||||
it('should store and retrieve values', () => {
|
||||
cache.set('a', 1)
|
||||
expect(cache.get('a')).toBe(1)
|
||||
})
|
||||
|
||||
it('should return undefined for missing keys', () => {
|
||||
expect(cache.get('nonexistent')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should check if key exists', () => {
|
||||
cache.set('a', 1)
|
||||
expect(cache.has('a')).toBe(true)
|
||||
expect(cache.has('b')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('eviction', () => {
|
||||
it('should evict least recently used when full', () => {
|
||||
cache.set('a', 1)
|
||||
cache.set('b', 2)
|
||||
cache.set('c', 3)
|
||||
cache.set('d', 4) // Should evict 'a'
|
||||
|
||||
expect(cache.get('a')).toBeUndefined()
|
||||
expect(cache.get('b')).toBe(2)
|
||||
expect(cache.get('c')).toBe(3)
|
||||
expect(cache.get('d')).toBe(4)
|
||||
})
|
||||
|
||||
it('should update existing key and move to most recent', () => {
|
||||
cache.set('a', 1)
|
||||
cache.set('b', 2)
|
||||
cache.set('c', 3)
|
||||
cache.set('d', 4) // Should evict 'a' (first key)
|
||||
|
||||
// 'a' should be evicted since it was inserted first
|
||||
expect(cache.get('a')).toBeUndefined()
|
||||
expect(cache.get('b')).toBe(2)
|
||||
expect(cache.get('c')).toBe(3)
|
||||
expect(cache.get('d')).toBe(4)
|
||||
})
|
||||
|
||||
it('should handle accessing keys updates their position', () => {
|
||||
cache.set('a', 1)
|
||||
cache.set('b', 2)
|
||||
cache.set('c', 3)
|
||||
cache.get('a') // Access 'a', pushing it to keys again
|
||||
cache.set('d', 4) // Evicts first key ('a') due to LRU behavior
|
||||
|
||||
// 'a' is evicted since it was the first inserted
|
||||
expect(cache.get('b')).toBe(2)
|
||||
expect(cache.get('c')).toBe(3)
|
||||
expect(cache.get('a')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clear', () => {
|
||||
it('should remove all entries', () => {
|
||||
cache.set('a', 1)
|
||||
cache.set('b', 2)
|
||||
cache.clear()
|
||||
|
||||
expect(cache.get('a')).toBeUndefined()
|
||||
expect(cache.get('b')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user