Compare commits

...

2 Commits

Author SHA1 Message Date
Ren Amamiya
72b9dcddc1 feat: add support for nip44 2026-04-08 11:54:00 +07:00
Ren Amamiya
387796faa3 chore: format and lint 2026-04-08 11:28:39 +07:00
33 changed files with 30936 additions and 25961 deletions

View File

@@ -1,148 +0,0 @@
{
"root": true,
"parserOptions": {
"ecmaVersion": 2020,
"ecmaFeatures": {
"jsx": true
},
"sourceType": "module",
"allowImportExportEverywhere": false
},
"env": {
"es6": true,
"node": true
},
"plugins": [
"react",
"babel"
],
"globals": {
"document": false,
"navigator": false,
"window": false,
"location": false,
"URL": false,
"URLSearchParams": false,
"fetch": false,
"EventSource": false,
"localStorage": false,
"sessionStorage": false
},
"rules": {
"react/jsx-uses-vars": 2,
"react/jsx-no-undef": 2,
"react/jsx-uses-react": 2,
"accessor-pairs": 2,
"arrow-spacing": [2, { "before": true, "after": true }],
"block-spacing": [2, "always"],
"brace-style": [2, "1tbs", { "allowSingleLine": true }],
"comma-dangle": 0,
"comma-spacing": [2, { "before": false, "after": true }],
"comma-style": [2, "last"],
"constructor-super": 2,
"curly": [0, "multi-line"],
"dot-location": [2, "property"],
"eol-last": 2,
"eqeqeq": [2, "allow-null"],
"generator-star-spacing": [2, { "before": true, "after": true }],
"handle-callback-err": [2, "^(err|error)$" ],
"indent": 0,
"jsx-quotes": [2, "prefer-double"],
"key-spacing": [2, { "beforeColon": false, "afterColon": true }],
"keyword-spacing": [2, { "before": true, "after": true }],
"new-cap": 0,
"new-parens": 0,
"no-array-constructor": 2,
"no-caller": 2,
"no-class-assign": 2,
"no-cond-assign": 2,
"no-const-assign": 2,
"no-control-regex": 0,
"no-debugger": 0,
"no-delete-var": 2,
"no-dupe-args": 2,
"no-dupe-class-members": 2,
"no-dupe-keys": 2,
"no-duplicate-case": 2,
"no-empty-character-class": 2,
"no-empty-pattern": 2,
"no-eval": 0,
"no-ex-assign": 2,
"no-extend-native": 2,
"no-extra-bind": 2,
"no-extra-boolean-cast": 2,
"no-extra-parens": [2, "functions"],
"no-fallthrough": 2,
"no-floating-decimal": 2,
"no-func-assign": 2,
"no-implied-eval": 2,
"no-inner-declarations": [0, "functions"],
"no-invalid-regexp": 2,
"no-irregular-whitespace": 2,
"no-iterator": 2,
"no-label-var": 2,
"no-labels": [2, { "allowLoop": false, "allowSwitch": false }],
"no-lone-blocks": 2,
"no-mixed-spaces-and-tabs": 2,
"no-multi-spaces": 2,
"no-multi-str": 2,
"no-multiple-empty-lines": [2, { "max": 2 }],
"no-native-reassign": 2,
"no-negated-in-lhs": 2,
"no-new": 0,
"no-new-func": 2,
"no-new-object": 2,
"no-new-require": 2,
"no-new-symbol": 2,
"no-new-wrappers": 2,
"no-obj-calls": 2,
"no-octal": 2,
"no-octal-escape": 2,
"no-path-concat": 0,
"no-proto": 2,
"no-redeclare": 2,
"no-regex-spaces": 2,
"no-return-assign": 0,
"no-self-assign": 2,
"no-self-compare": 2,
"no-sequences": 2,
"no-shadow-restricted-names": 2,
"no-spaced-func": 2,
"no-sparse-arrays": 2,
"no-this-before-super": 2,
"no-throw-literal": 2,
"no-trailing-spaces": 2,
"no-undef": 2,
"no-undef-init": 2,
"no-unexpected-multiline": 2,
"no-unneeded-ternary": [2, { "defaultAssignment": false }],
"no-unreachable": 2,
"no-unused-vars": [2, { "vars": "local", "args": "none", "varsIgnorePattern": "^_"}],
"no-useless-call": 2,
"no-useless-constructor": 2,
"no-with": 2,
"one-var": [0, { "initialized": "never" }],
"operator-linebreak": [2, "after", { "overrides": { "?": "before", ":": "before" } }],
"padded-blocks": [2, "never"],
"quotes": [2, "single", { "avoidEscape": true, "allowTemplateLiterals": true }],
"semi": [2, "never"],
"semi-spacing": [2, { "before": false, "after": true }],
"space-before-blocks": [2, "always"],
"space-before-function-paren": 0,
"space-in-parens": [2, "never"],
"space-infix-ops": 2,
"space-unary-ops": [2, { "words": true, "nonwords": false }],
"spaced-comment": 0,
"template-curly-spacing": [2, "never"],
"use-isnan": 2,
"valid-typeof": 2,
"wrap-iife": [2, "any"],
"yield-star-spacing": [2, "both"],
"yoda": 0,
"no-unsafe-optional-chaning": 0
}
}

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
@jsr:registry=https://npm.jsr.io

View File

@@ -1,9 +0,0 @@
arrowParens: avoid
bracketSpacing: false
jsxBracketSameLine: false
printWidth: 80
proseWrap: preserve
semi: false
singleQuote: true
trailingComma: none
useTabs: false

40
AGENTS.md Normal file
View File

@@ -0,0 +1,40 @@
# AGENTS.md
## Build Commands
```bash
bun run dev # Development: runs build.js + tailwindcss --watch
bun run build # Production build → extension/output
bun run package:chrome # Creates extension/releases/nostrconnect_chrome.zip
bun run package:firefox # Creates extension/releases/nostrconnect_firefox.xpi
bun run lint # Run Biome linter
bun run format # Format with Biome (auto-fix)
bun run test # Run tests with Vitest
bun run test:watch # Run tests in watch mode
```
## Key Facts
- **Package manager**: bun (preferred) or pnpm. Uses `@jsr` registry for `@nostr/tools`
- **Build output**: `extension/output/` (not `dist/`)
- **Ignore `extension/output/`**: It contains generated JS/HTML/CSS and is gitignored
- **Tailwind source**: `extension/style.css``extension/output/style.css`
## Architecture
| File | Purpose |
| ---------------------------------------- | --------------------------------------------------------- |
| `background.js` | Core logic: state, permissions, crypto (signEvent, nip04) |
| `nostr-provider.js` | Injected into web pages, provides `window.nostr` |
| `content-script.js` | Bridges provider ↔ background via postMessage |
| `popup.jsx`, `prompt.jsx`, `options.jsx` | React UI components |
| `extension/chrome/manifest.json` | Chrome Manifest V3 config |
| `extension/firefox/manifest.json` | Firefox Manifest V2 config |
The build script (`build.js`) auto-selects the correct manifest based on `prod`/`firefox` args.
## Code Style
- No semicolons
- Single quotes
- Biome (configured in `biome.json`)

View File

@@ -1,13 +0,0 @@
module.exports = api => {
return {
presets: [
[
'@quasar/babel-preset-app',
api.caller(caller => caller && caller.target === 'node')
? { targets: { node: 'current' } }
: {}
]
]
}
}

63
biome.json Normal file
View File

@@ -0,0 +1,63 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.10/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": true,
"ignore": [
"extension/output/**",
"extension/**/*.test.js",
"extension/test-utils.js",
"extension/style.css"
]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 80
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"correctness": {
"noUnusedVariables": "warn"
},
"style": {
"noNonNullAssertion": "off"
},
"a11y": {
"recommended": true
},
"complexity": {
"recommended": true
},
"suspicious": {
"recommended": true
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "asNeeded",
"trailingCommas": "none",
"arrowParentheses": "always"
}
}
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "asNeeded",
"trailingCommas": "none",
"arrowParentheses": "always"
}
}
}

1790
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,225 +1,273 @@
import browser from 'webextension-polyfill'
import browser from "webextension-polyfill";
import {
validateEvent,
getSignature,
finalizeEvent,
getEventHash,
getPublicKey,
nip19
} from 'nostr-tools'
import {nip04} from 'nostr-tools'
import {Mutex} from 'async-mutex'
nip19,
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 {
NO_PERMISSIONS_REQUIRED,
getPermissionStatus,
updatePermission,
showNotification
} from './common'
showNotification,
getPosition,
} from "./common";
const {encrypt, decrypt} = nip04
const { encrypt, decrypt } = nip04;
let openPrompt = null
let 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) => {
let {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) => {
let 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 => {
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': {
let {protocol_handler: ph} = await browser.storage.local.get([
'protocol_handler'
])
if (!ph) return false
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;
let {url} = params
let raw = url.split('nostr:')[1]
let {type, data} = nip19.decode(raw)
let replacements = {
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'
: type === "nprofile"
? data.pubkey
: type === 'nevent'
: 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
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();
let allowed = await getPermissionStatus(
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 {
let id = Math.random().toString().slice(4)
let qs = new URLSearchParams({
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
let accept = await new Promise((resolve, reject) => {
openPrompt = {resolve, reject}
const accept = await new Promise((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
let results = await browser.storage.local.get('private_key')
if (!results || !results.private_key) {
return {error: 'no private key found'}
const results = await browser.storage.local.get("private_key");
if (!results?.private_key) {
return { error: "no private key found" };
}
let sk = results.private_key
const sk = results.private_key;
try {
switch (type) {
case 'getPublicKey': {
return getPublicKey(sk)
case "getPublicKey": {
return getPublicKey(hexToBytes(sk));
}
case 'getRelays': {
let 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': {
let {event} = params
case "signEvent": {
const { event } = params;
if (!event.pubkey) event.pubkey = getPublicKey(sk)
if (!event.id) event.id = getEventHash(event)
if (!validateEvent(event)) return {error: {message: 'invalid event'}}
if (!event.pubkey) event.pubkey = getPublicKey(hexToBytes(sk));
if (!event.id) event.id = getEventHash(event);
if (!validateEvent(event))
return { error: { message: "invalid event" } };
event.sig = await getSignature(event, sk)
return event
const signedEvent = finalizeEvent(event, hexToBytes(sk));
return signedEvent;
}
case 'nip04.encrypt': {
let {peer, plaintext} = params
return encrypt(sk, peer, plaintext)
case "nip04.encrypt": {
const { peer, plaintext } = params;
return encrypt(sk, peer, plaintext);
}
case 'nip04.decrypt': {
let {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);
}
}
}

View 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'
)
})
})
})

View File

@@ -1,116 +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) {
let {policies} = await browser.storage.local.get('policies')
const { policies } = await browser.storage.local.get("policies");
let answers = [true, false]
const answers = [true, false];
for (let i = 0; i < answers.length; i++) {
let accept = answers[i]
let {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 {
// if this doesn't match we just continue so it will either match for the opposite answer (reject)
// or it will end up returning undefined at the end
continue
}
} 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) {
let {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
let 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
})
Object.keys(existingConditions.kinds).forEach((kind) => {
conditions.kinds[kind] = true;
});
}
}
}
// if we have a reverse policy (accept / reject) that is exactly equal to this, remove it
let other = !accept
let 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) {
let {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) {
let ok = await browser.storage.local.get('notifications')
if (ok) {
let 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
View 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)
})
})
})

View File

@@ -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
let 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
)
})
);
});

View File

@@ -1,5 +1,3 @@
import React from 'react'
export function LogoIcon() {
return (
<svg
@@ -8,6 +6,7 @@ export function LogoIcon() {
height="56"
fill="none"
viewBox="0 0 56 56"
aria-label="Nostr Connect logo"
>
<rect width="56" height="56" fill="#EEECFD" rx="16"></rect>
<rect
@@ -70,6 +69,7 @@ export function SettingsIcon(props) {
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
aria-label="Settings"
{...props}
>
<path

View File

@@ -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,8 +32,18 @@ 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) {
let id = Math.random().toString().slice(-4)
const id = Math.random().toString().slice(-4)
console.log(
'%c[nostrconnect:%c' +
id +
@@ -60,7 +74,7 @@ window.nostr = {
}
}
window.addEventListener('message', message => {
window.addEventListener('message', (message) => {
if (
!message.data ||
message.data.response === null ||
@@ -71,8 +85,8 @@ window.addEventListener('message', message => {
return
if (message.data.response.error) {
let error = new Error(
`${EXTENSION}: ` + message.data.response.error.message
const error = new Error(
`${EXTENSION}: ${message.data.response.error.message}`
)
error.stack = message.data.response.error.stack
window.nostr._requests[message.data.id].reject(error)
@@ -104,7 +118,9 @@ async function replaceNostrSchemeLink(e) {
if (e.target.tagName !== 'A' || !e.target.href.startsWith('nostr:')) return
if (replacing === false) return
let response = await window.nostr._call('replaceURL', {url: e.target.href})
const response = await window.nostr._call('replaceURL', {
url: e.target.href
})
if (response === false) {
replacing = false
return

View 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')
})
})
})

View File

@@ -1,7 +1,7 @@
import browser from 'webextension-polyfill'
import React, {useState, useCallback, useEffect} from 'react'
import { useState, useCallback, useEffect } from 'react'
import { render } from 'react-dom'
import {generatePrivateKey, nip19} from 'nostr-tools'
import { generateSecretKey, nip19, utils } from 'nostr-tools'
import QRCode from 'react-qr-code'
import * as Tabs from '@radix-ui/react-tabs'
import { LogoIcon } from './icons'
@@ -9,34 +9,57 @@ import {removePermissions} from './common'
import * as Checkbox from '@radix-ui/react-checkbox'
function Options() {
let [privKey, setPrivKey] = useState('')
let [relays, setRelays] = useState([])
let [newRelayURL, setNewRelayURL] = useState('')
let [policies, setPermissions] = useState([])
let [protocolHandler, setProtocolHandler] = useState('https://njump.me/{raw}')
let [hidingPrivateKey, hidePrivateKey] = useState(true)
let [showNotifications, setNotifications] = useState(false)
let [messages, setMessages] = useState([])
let [handleNostrLinks, setHandleNostrLinks] = useState(false)
let [showProtocolHandlerHelp, setShowProtocolHandlerHelp] = useState(false)
let [unsavedChanges, setUnsavedChanges] = useState([])
const [privKey, setPrivKey] = useState('')
const [relays, setRelays] = useState([])
const [newRelayURL, setNewRelayURL] = useState('')
const [policies, setPermissions] = useState([])
const [protocolHandler, setProtocolHandler] = useState(
'https://njump.me/{raw}'
)
const [hidingPrivateKey, hidePrivateKey] = useState(true)
const [showNotifications, setNotifications] = useState(false)
const [messages, setMessages] = useState([])
const [handleNostrLinks, setHandleNostrLinks] = useState(false)
const [showProtocolHandlerHelp, setShowProtocolHandlerHelp] = useState(false)
const [unsavedChanges, setUnsavedChanges] = useState([])
const showMessage = useCallback(msg => {
const showMessage = useCallback((msg) => {
messages.push(msg)
setMessages(messages)
setTimeout(() => setMessages([]), 3000)
})
const loadPermissions = useCallback(async () => {
const { policies = {} } = await browser.storage.local.get('policies')
const list = []
Object.entries(policies).forEach(([host, accepts]) => {
Object.entries(accepts).forEach(([accept, types]) => {
Object.entries(types).forEach(([type, { conditions, created_at }]) => {
list.push({
host,
type,
accept,
conditions,
created_at
})
})
})
})
setPermissions(list)
}, [])
useEffect(() => {
browser.storage.local
.get(['private_key', 'relays', 'protocol_handler', 'notifications'])
.then(results => {
.then((results) => {
if (results.private_key) {
setPrivKey(nip19.nsecEncode(results.private_key))
}
if (results.relays) {
let relaysList = []
for (let url in results.relays) {
const relaysList = []
for (const url in results.relays) {
relaysList.push({
url,
policy: results.relays[url]
@@ -57,28 +80,7 @@ function Options() {
useEffect(() => {
loadPermissions()
}, [])
async function loadPermissions() {
let {policies = {}} = await browser.storage.local.get('policies')
let list = []
Object.entries(policies).forEach(([host, accepts]) => {
Object.entries(accepts).forEach(([accept, types]) => {
Object.entries(types).forEach(([type, {conditions, created_at}]) => {
list.push({
host,
type,
accept,
conditions,
created_at
})
})
})
})
setPermissions(list)
}
}, [loadPermissions])
return (
<div className="w-screen h-screen flex flex-col items-center justify-center">
@@ -179,7 +181,7 @@ function Options() {
<div className="font-semibold text-base">Preferred Relays:</div>
<div className="flex flex-col gap-2">
{relays.map(({ url, policy }, i) => (
<div key={i} className="flex items-center gap-4">
<div key={url} className="flex items-center gap-4">
<input
value={url}
onChange={changeRelayURL.bind(null, i)}
@@ -205,6 +207,7 @@ function Options() {
strokeWidth={1.5}
stroke="currentColor"
className="w-4 h-4"
aria-hidden="true"
>
<path
strokeLinecap="round"
@@ -240,6 +243,7 @@ function Options() {
strokeWidth={1.5}
stroke="currentColor"
className="w-4 h-4"
aria-hidden="true"
>
<path
strokeLinecap="round"
@@ -258,6 +262,7 @@ function Options() {
</div>
</div>
<button
type="button"
onClick={removeRelay.bind(null, i)}
className="shrink-0 px-3 w-24 h-9 font-semibold border border-primary shadow-sm rounded-lg inline-flex items-center justify-center disabled:text-muted"
>
@@ -268,14 +273,15 @@ function Options() {
<div className="flex gap-2">
<input
value={newRelayURL}
onChange={e => setNewRelayURL(e.target.value)}
onKeyDown={e => {
onChange={(e) => setNewRelayURL(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') addNewRelay()
}}
placeholder="wss://"
className="flex-1 h-9 bg-transparent border px-3 py-1 border-primary rounded-lg placeholder:text-muted"
/>
<button
type="button"
disabled={!newRelayURL}
onClick={addNewRelay}
className="shrink-0 px-3 w-24 h-9 font-semibold border border-primary shadow-sm rounded-lg inline-flex items-center justify-center disabled:text-muted"
@@ -344,6 +350,7 @@ function Options() {
</td>
<td>
<button
type="button"
onClick={handleRevoke}
data-host={host}
data-accept={accept}
@@ -358,11 +365,11 @@ function Options() {
)}
{!policies.length && (
<tr>
{Array(5)
.fill('N/A')
.map((v, i) => (
<td key={i}>{v}</td>
))}
<td>N/A</td>
<td>N/A</td>
<td>N/A</td>
<td>N/A</td>
<td>N/A</td>
</tr>
)}
</tbody>
@@ -387,6 +394,7 @@ function Options() {
strokeWidth={1.5}
stroke="currentColor"
className="w-4 h-4"
aria-hidden="true"
>
<path
strokeLinecap="round"
@@ -413,6 +421,7 @@ function Options() {
strokeWidth={1.5}
stroke="currentColor"
className="w-5 h-5"
aria-hidden="true"
>
<path
strokeLinecap="round"
@@ -438,6 +447,7 @@ function Options() {
strokeWidth={1.5}
stroke="currentColor"
className="w-4 h-4"
aria-hidden="true"
>
<path
strokeLinecap="round"
@@ -458,7 +468,10 @@ function Options() {
onChange={handleChangeProtocolHandler}
/>
{!showProtocolHandlerHelp && (
<button onClick={changeShowProtocolHandlerHelp}>
<button
type="button"
onClick={changeShowProtocolHandlerHelp}
>
?
</button>
)}
@@ -487,6 +500,7 @@ examples:
</div>
</div>
<button
type="button"
disabled={!unsavedChanges.length}
onClick={saveChanges}
className="w-full h-10 bg-primary rounded-xl font-bold inline-flex items-center justify-center text-white disabled:cursor-not-allowed disabled:opacity-70 transform active:translate-y-1 transition-transform ease-in-out duration-75"
@@ -498,13 +512,14 @@ examples:
)
async function handleKeyChange(e) {
let key = e.target.value.toLowerCase().trim()
const key = e.target.value.toLowerCase().trim()
setPrivKey(key)
addUnsavedChanges('private_key')
}
async function generate() {
setPrivKey(nip19.nsecEncode(generatePrivateKey()))
const sk = generateSecretKey()
setPrivKey(nip19.nsecEncode(utils.bytesToHex(sk)))
addUnsavedChanges('private_key')
}
@@ -517,7 +532,7 @@ examples:
let hexOrEmptyKey = privKey
try {
let {type, data} = nip19.decode(privKey)
const { type, data } = nip19.decode(privKey)
if (type === 'nsec') hexOrEmptyKey = data
} catch (_) {}
@@ -580,7 +595,7 @@ examples:
}
async function handleRevoke(e) {
let {host, accept, type} = e.target.dataset
const { host, accept, type } = e.target.dataset
if (
window.confirm(
`revoke all ${
@@ -601,7 +616,7 @@ examples:
}
async function requestBrowserNotificationPermissions() {
let granted = await browser.permissions.request({
const granted = await browser.permissions.request({
permissions: ['notifications']
})
if (!granted) setNotifications(false)
@@ -646,14 +661,14 @@ examples:
}
function addUnsavedChanges(section) {
if (!unsavedChanges.find(s => s === section)) {
if (!unsavedChanges.find((s) => s === section)) {
unsavedChanges.push(section)
setUnsavedChanges(unsavedChanges)
}
}
async function saveChanges() {
for (let section of unsavedChanges) {
for (const section of unsavedChanges) {
switch (section) {
case 'private_key':
await saveKey()

File diff suppressed because it is too large Load Diff

View File

@@ -1,116 +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) {
let {policies} = await browser.storage.local.get('policies')
const { policies } = await browser.storage.local.get("policies");
let answers = [true, false]
const answers = [true, false];
for (let i = 0; i < answers.length; i++) {
let accept = answers[i]
let {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 {
// if this doesn't match we just continue so it will either match for the opposite answer (reject)
// or it will end up returning undefined at the end
continue
}
} 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) {
let {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
let 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
})
Object.keys(existingConditions.kinds).forEach((kind) => {
conditions.kinds[kind] = true;
});
}
}
}
// if we have a reverse policy (accept / reject) that is exactly equal to this, remove it
let other = !accept
let 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) {
let {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) {
let ok = await browser.storage.local.get('notifications')
if (ok) {
let 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,
};
}

View File

@@ -3,12 +3,7 @@
"description": "Nostr Signer Extension",
"version": "0.1.2",
"homepage_url": "https://github.com/reyamir/nostr-connect",
"manifest_version": 2,
"browser_specific_settings": {
"gecko": {
"id": "{e665d138-0e5b-4b7a-ab91-7af834eda7a2}"
}
},
"manifest_version": 3,
"icons": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
@@ -17,19 +12,25 @@
},
"options_page": "options.html",
"background": {
"scripts": ["background.build.js"]
"service_worker": "background.build.js"
},
"browser_action": {
"action": {
"default_title": "Nostr Connect",
"default_popup": "popup.html"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content-script.build.js"]
"js": ["content-script.build.js"],
"all_frames": true
}
],
"permissions": ["storage"],
"optional_permissions": ["notifications"],
"web_accessible_resources": ["nostr-provider.js"]
"web_accessible_resources": [
{
"resources": ["nostr-provider.js"],
"matches": ["https://*/*", "http://localhost:*/*"]
}
]
}

View File

@@ -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,8 +32,18 @@ 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) {
let id = Math.random().toString().slice(-4)
const id = Math.random().toString().slice(-4)
console.log(
'%c[nostrconnect:%c' +
id +
@@ -60,7 +74,7 @@ window.nostr = {
}
}
window.addEventListener('message', message => {
window.addEventListener('message', (message) => {
if (
!message.data ||
message.data.response === null ||
@@ -71,8 +85,8 @@ window.addEventListener('message', message => {
return
if (message.data.response.error) {
let error = new Error(
`${EXTENSION}: ` + message.data.response.error.message
const error = new Error(
`${EXTENSION}: ${message.data.response.error.message}`
)
error.stack = message.data.response.error.stack
window.nostr._requests[message.data.id].reject(error)
@@ -104,7 +118,9 @@ async function replaceNostrSchemeLink(e) {
if (e.target.tagName !== 'A' || !e.target.href.startsWith('nostr:')) return
if (replacing === false) return
let response = await window.nostr._call('replaceURL', {url: e.target.href})
const response = await window.nostr._call('replaceURL', {
url: e.target.href
})
if (response === false) {
replacing = false
return

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,15 +1,15 @@
import browser from 'webextension-polyfill'
import { render } from 'react-dom'
import { getPublicKey, nip19 } from 'nostr-tools'
import React, {useState, useMemo, useEffect} from 'react'
import { useState, useMemo, useEffect } from 'react'
import QRCode from 'react-qr-code'
import { SettingsIcon } from './icons'
import { minidenticon } from 'minidenticons'
import * as Tabs from '@radix-ui/react-tabs'
function Popup() {
let [keys, setKeys] = useState(null)
let avatarURI = useMemo(
const [keys, setKeys] = useState(null)
const avatarURI = useMemo(
() =>
keys
? 'data:image/svg+xml;utf8,' +
@@ -25,27 +25,27 @@ function Popup() {
}
useEffect(() => {
browser.storage.local.get(['private_key', 'relays']).then(results => {
browser.storage.local.get(['private_key', 'relays']).then((results) => {
if (results.private_key) {
let hexKey = getPublicKey(results.private_key)
let npubKey = nip19.npubEncode(hexKey)
const hexKey = getPublicKey(results.private_key)
const npubKey = nip19.npubEncode(hexKey)
setKeys({ npub: npubKey, hex: hexKey })
if (results.relays) {
let relaysList = []
for (let url in results.relays) {
const relaysList = []
for (const url in results.relays) {
if (results.relays[url].write) {
relaysList.push(url)
if (relaysList.length >= 3) break
}
}
if (relaysList.length) {
let nprofileKey = nip19.nprofileEncode({
const nprofileKey = nip19.nprofileEncode({
pubkey: hexKey,
relays: relaysList
})
setKeys(prev => ({...prev, nprofile: nprofileKey}))
setKeys((prev) => ({ ...prev, nprofile: nprofileKey }))
}
}
} else {
@@ -71,6 +71,7 @@ function Popup() {
strokeWidth={1.5}
stroke="currentColor"
className="w-6 h-6"
aria-hidden="true"
>
<path
strokeLinecap="round"
@@ -95,6 +96,7 @@ function Popup() {
<img
src={avatarURI}
className="w-9 h-9 rounded-full bg-muted"
alt="Avatar"
/>
) : (
<div className="w-9 h-9 rounded-full bg-muted" />

View File

@@ -1,6 +1,6 @@
import browser from 'webextension-polyfill'
import { render } from 'react-dom'
import React, {useState} from 'react'
import { useState } from 'react'
import { PERMISSION_NAMES } from './common'
import { LogoIcon } from './icons'
@@ -9,23 +9,23 @@ import * as Checkbox from '@radix-ui/react-checkbox'
function Prompt() {
const [isRemember, setIsRemember] = useState(false)
let qs = new URLSearchParams(location.search)
let id = qs.get('id')
let host = qs.get('host')
let type = qs.get('type')
const qs = new URLSearchParams(location.search)
const id = qs.get('id')
const host = qs.get('host')
const type = qs.get('type')
let params, event
try {
params = JSON.parse(qs.get('params'))
if (Object.keys(params).length === 0) params = null
else if (params.event) event = params.event
} catch (err) {
} catch (_err) {
params = null
}
function authorizeHandler(accept) {
const conditions = isRemember ? {} : null
return function (ev) {
return (ev) => {
ev.preventDefault()
browser.runtime.sendMessage({
prompt: true,
@@ -73,6 +73,7 @@ function Prompt() {
strokeWidth={1.5}
stroke="currentColor"
className="w-4 h-4"
aria-hidden="true"
>
<path
strokeLinecap="round"
@@ -88,12 +89,14 @@ function Prompt() {
</div>
<div className="flex gap-3">
<button
type="button"
onClick={authorizeHandler(false)}
className="flex-1 h-10 rounded-lg shadow-sm border border-primary inline-flex items-center justify-center font-semibold"
>
Reject
</button>
<button
type="button"
onClick={authorizeHandler(true)}
className="flex-1 h-10 rounded-lg shadow-sm border border-secondary bg-primary text-white inline-flex items-center justify-center font-semibold"
>

86
extension/test-utils.js Normal file
View 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
View 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
View 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()
})
})
})

View File

@@ -1,33 +1,39 @@
{
"license": "WTFPL",
"dependencies": {
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-tabs": "^1.0.4",
"@nostr/tools": "npm:@jsr/nostr__tools@^2.23.3",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-tabs": "^1.1.13",
"async-mutex": "^0.3.2",
"esbuild": "^0.14.54",
"eslint": "^8.54.0",
"eslint-plugin-babel": "^5.3.1",
"eslint-plugin-react": "^7.33.2",
"events": "^3.3.0",
"minidenticons": "^4.2.0",
"nostr-tools": "^1.17.0",
"prettier": "^2.8.8",
"minidenticons": "^4.2.1",
"nostr-tools": "^2.8.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-native-svg": "^13.14.0",
"react-qr-code": "^2.0.12",
"react-native-svg": "^13.14.1",
"react-qr-code": "^2.0.18",
"use-boolean-state": "^1.0.2",
"use-debounce": "^7.0.1",
"webextension-polyfill": "^0.8.0"
},
"scripts": {
"dev": "./build.js; pnpm exec tailwindcss -i ./extension/style.css -o ./extension/build/style.css --watch",
"build": "pnpm exec tailwindcss -i ./extension/style.css -o ./extension/output/style.css; ./build.js prod",
"package:chrome": "pnpm exec tailwindcss -i ./extension/style.css -o ./extension/output/style.css --minify; ./build.js prod; cd extension/output; zip -r archive *; cd ../../; mv extension/output/archive.zip extension/releases/nostrconnect_chrome.zip",
"package:firefox": "pnpm exec tailwindcss -i ./extension/style.css -o ./extension/output/style.css --minify; ./build.js prod firefox; cd extension/output; zip -r archive *; cd ../../; mv extension/output/archive.zip extension/releases/nostrconnect_firefox.xpi"
"dev": "./build.js; bunx tailwindcss -i ./extension/style.css -o ./extension/output/style.css --watch",
"build": "bunx tailwindcss -i ./extension/style.css -o ./extension/output/style.css; ./build.js prod",
"package:chrome": "bunx tailwindcss -i ./extension/style.css -o ./extension/output/style.css --minify; ./build.js prod; cd extension/output; zip -r archive *; cd ../../; mv extension/output/archive.zip extension/releases/nostrconnect_chrome.zip",
"package:firefox": "bunx tailwindcss -i ./extension/style.css -o ./extension/output/style.css --minify; ./build.js prod firefox; cd extension/output; zip -r archive *; cd ../../; mv extension/output/archive.zip extension/releases/nostrconnect_firefox.xpi",
"lint": "biome lint ./extension/background.js ./extension/common.js ./extension/nostr-provider.js ./extension/content-script.js ./extension/popup.jsx ./extension/prompt.jsx ./extension/options.jsx ./extension/icons.jsx ./extension/utils.js",
"format": "biome format --write ./extension/background.js ./extension/common.js ./extension/nostr-provider.js ./extension/content-script.js ./extension/popup.jsx ./extension/prompt.jsx ./extension/options.jsx ./extension/icons.jsx ./extension/utils.js",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"devDependencies": {
"@biomejs/biome": "^2.4.10",
"@vitejs/plugin-react": "^6.0.1",
"esbuild-plugin-copy": "^2.1.1",
"tailwindcss": "^3.3.5"
"jsdom": "^29.0.2",
"tailwindcss": "^3.4.19",
"vitest": "^4.1.3"
}
}

10170
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
allowBuilds:
esbuild: false

14
vitest.config.js Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['extension/**/*.test.{js,jsx}'],
coverage: {
reporter: ['text', 'json', 'html'],
include: ['extension/**/*.js'],
exclude: ['extension/output/**', 'extension/**/*.test.js']
}
}
})