diff --git a/extension/background.js b/extension/background.js index c40e980..57a496b 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1,8 +1,10 @@ import browser from 'webextension-polyfill' +import { bytesToHex, hexToBytes } from '@noble/hashes/utils' import {validateEvent, finalizeEvent, getPublicKey} from 'nostr-tools/pure' import * as nip19 from 'nostr-tools/nip19' import * as nip04 from 'nostr-tools/nip04' import * as nip44 from 'nostr-tools/nip44' +import * as nip46 from 'nostr-tools/nip46' import {Mutex} from 'async-mutex' import {LRUCache} from './utils' @@ -19,6 +21,58 @@ let promptMutex = new Mutex() let releasePromptMutex = () => {} let secretsCache = new LRUCache(100) let previousSk = null +let cachedBunkerSigner = { + privateKeyHex: '', + bunkerUrl: '', + signerPromise: null, +} + +async function createBunkerSigner(privateKey, bunkerUrl) { + console.info('creating bunker signer') + const pointer = await nip46.parseBunkerInput(bunkerUrl) + console.info('bunker signer', privateKey, pointer) + const signer = new nip46.BunkerSigner(privateKey, pointer, { + onauth: async (authUrl) => { + console.log("onauth", authUrl) + const {top, left} = await getPosition(authWidth, authHeight) + browser.windows.create({ + url: authUrl, + type: 'popup', + width: authWidth, + height: authHeight, + top: top, + left: left + }) + } + }) + await signer.connect() + return signer +} + +function getBunkerSigner(privateKeyStringOrUint8Array, bunkerUrl) { + const privateKey = privateKeyStringOrUint8Array instanceof Uint8Array ? privateKeyStringOrUint8Array : hexToBytes(privateKeyStringOrUint8Array) + const privateKeyHex = bytesToHex(privateKey) + if (cachedBunkerSigner.privateKeyHex !== privateKeyHex || cachedBunkerSigner.bunkerUrl !== bunkerUrl || !cachedBunkerSigner.signerPromise) { + if (cachedBunkerSigner.signerPromise) { + cachedBunkerSigner.signerPromise.then((oldBunkerSigner) => { + if (oldBunkerSigner) { + return oldBunkerSigner.close() + } + }) + } + const newCachedBunkerSigner = { + privateKeyHex, + bunkerUrl, + signerPromise: createBunkerSigner(privateKey, bunkerUrl).catch((err) => { + // will retry parsing on next call + newCachedBunkerSigner.signerPromise = null + throw err + }) + } + cachedBunkerSigner = newCachedBunkerSigner + } + return cachedBunkerSigner.signerPromise +} function getSharedSecret(sk, peer) { // Detect a key change and erase the cache if they changed their key @@ -40,6 +94,9 @@ function getSharedSecret(sk, peer) { const width = 340 const height = 360 +const authWidth = 800 +const authHeight = 800 + browser.runtime.onInstalled.addListener((_, __, reason) => { if (reason === 'install') browser.runtime.openOptionsPage() }) @@ -173,16 +230,33 @@ async function handleContentScriptMessage({type, params, host}) { } // if we're here this means it was accepted - let results = await browser.storage.local.get('private_key') + let results = await browser.storage.local.get(['private_key', 'bunker_url']) if (!results || !results.private_key) { return {error: {message: 'no private key found'} } } + let bunkerSigner = null + if (results.bunker_url) { + try { + bunkerSigner = await getBunkerSigner(results.private_key, results.bunker_url) + } catch (err) { + return {error: {message: 'failed to connect to bunker url'}} + } + } + let sk = results.private_key try { switch (type) { case 'getPublicKey': { + if (bunkerSigner) { + try { + const bunkerResult = await bunkerSigner.getPublicKey() + return bunkerResult + } catch (err) { + return {error: {message: 'failed to get public key from bunker'}} + } + } return getPublicKey(sk) } case 'getRelays': { @@ -190,6 +264,14 @@ async function handleContentScriptMessage({type, params, host}) { return results.relays || {} } case 'signEvent': { + if (bunkerSigner) { + try { + const bunkerResult = await bunkerSigner.signEvent(params.event) + return bunkerResult + } catch (err) { + return {error: {message: 'failed to sign event using bunker'}} + } + } const event = finalizeEvent(params.event, sk) return validateEvent(event) @@ -198,20 +280,52 @@ async function handleContentScriptMessage({type, params, host}) { } case 'nip04.encrypt': { let {peer, plaintext} = params + if (bunkerSigner) { + try { + const bunkerResult = await bunkerSigner.nip04Encrypt(peer, plaintext) + return bunkerResult + } catch (err) { + return {error: {message: 'failed to encrypt event using bunker'}} + } + } return nip04.encrypt(sk, peer, plaintext) } case 'nip04.decrypt': { let {peer, ciphertext} = params + if (bunkerSigner) { + try { + const bunkerResult = await bunkerSigner.nip04Decrypt(peer, ciphertext) + return bunkerResult + } catch (err) { + return {error: {message: 'failed to encrypt event using bunker'}} + } + } return nip04.decrypt(sk, peer, ciphertext) } case 'nip44.encrypt': { const {peer, plaintext} = params + if (bunkerSigner) { + try { + const bunkerResult = await bunkerSigner.nip44Encrypt(peer, plaintext) + return bunkerResult + } catch (err) { + return {error: {message: 'failed to encrypt event using bunker'}} + } + } const key = getSharedSecret(sk, peer) return nip44.v2.encrypt(plaintext, key) } case 'nip44.decrypt': { const {peer, ciphertext} = params + if (bunkerSigner) { + try { + const bunkerResult = await bunkerSigner.nip44Decrypt(peer, ciphertext) + return bunkerResult + } catch (err) { + return {error: {message: 'failed to encrypt event using bunker'}} + } + } const key = getSharedSecret(sk, peer) return nip44.v2.decrypt(ciphertext, key) diff --git a/extension/options.jsx b/extension/options.jsx index 2b44d13..831f451 100644 --- a/extension/options.jsx +++ b/extension/options.jsx @@ -1,6 +1,7 @@ import { bytesToHex, hexToBytes } from '@noble/hashes/utils' import { getPublicKey } from 'nostr-tools' import * as nip19 from 'nostr-tools/nip19' +import * as nip46 from 'nostr-tools/nip46' import { decrypt, encrypt } from 'nostr-tools/nip49' import { generateSecretKey } from 'nostr-tools/pure' import qrcodeParser from 'qrcode-parser' @@ -11,6 +12,9 @@ import QrReader from 'react-qr-scanner' import browser from 'webextension-polyfill' import { removePermissions } from './common' +const bunkerValidityCheckDebounceMs = 400 +const bunkerConnectionTimeoutMs = 30000 + function Options() { let [privKey, setPrivKey] = useState(null) let [privKeyInput, setPrivKeyInput] = useState('') @@ -19,6 +23,10 @@ function Options() { let [errorMessage, setErrorMessage] = useState('') let [successMessage, setSuccessMessage] = useState('') let [relays, setRelays] = useState([]) + const [bunkerUrl, setBunkerUrl] = useState('') + const [bunkerUrlInput, setBunkerUrlInput] = useState('') + const [isSaving, setIsSaving] = useState(false) + const [connectionTestStage, setConnectionTestStage] = useState(null) let [newRelayURL, setNewRelayURL] = useState('') let [policies, setPermissions] = useState([]) let [protocolHandler, setProtocolHandler] = useState('https://njump.me/{raw}') @@ -46,7 +54,7 @@ function Options() { useEffect(() => { browser.storage.local - .get(['private_key', 'relays', 'protocol_handler', 'notifications']) + .get(['private_key', 'relays', 'protocol_handler', 'notifications', 'bunker_url']) .then(results => { if (results.private_key) { let prvKey = results.private_key @@ -72,6 +80,10 @@ function Options() { if (results.notifications) { setNotifications(true) } + if (results.bunker_url) { + setBunkerUrl(results.bunker_url) + setBunkerUrlInput(results.bunker_url) + } }) }, []) @@ -102,6 +114,28 @@ function Options() { setTimeout(() => setWarningMessage(''), 5000) }, [warningMessage]) + useEffect(() => { + if (bunkerUrl === bunkerUrlInput) { + return + } + const debounceTimeout = setTimeout(async () => { + try { + if (bunkerUrlInput !== '') { + const newBunkerPointer = await nip46.parseBunkerInput(bunkerUrlInput) + if (!newBunkerPointer) { + // Unpraseable url - do not allow saving + return + } + } + setBunkerUrl(bunkerUrlInput) + addUnsavedChanges('bunker_url') + } catch (err) { + /***/ + } + }, bunkerValidityCheckDebounceMs) + return () => clearTimeout(debounceTimeout) + }, [bunkerUrl, bunkerUrlInput, setBunkerUrl, addUnsavedChanges]) + async function loadQrCodeFromFile(type = 'image/*') { setScanning(false) const input = document.createElement('input') @@ -421,6 +455,46 @@ function Options() { +