working v1.
This commit is contained in:
@@ -2,8 +2,41 @@ import browser from 'webextension-polyfill'
|
||||
import {Buffer} from 'buffer'
|
||||
import {validateEvent, signEvent, getEventHash, getPublicKey} from 'nostr-tools'
|
||||
|
||||
import {
|
||||
PERMISSIONS_REQUIRED,
|
||||
readPermissionLevel,
|
||||
updatePermission
|
||||
} from './common'
|
||||
|
||||
const prompts = {}
|
||||
|
||||
browser.runtime.onMessage.addListener(async (req, sender) => {
|
||||
let {type, params, host} = req
|
||||
let {prompt} = req
|
||||
|
||||
if (prompt) {
|
||||
return handlePromptMessage(req, sender)
|
||||
} else {
|
||||
return handleContentScriptMessage(req)
|
||||
}
|
||||
})
|
||||
|
||||
async function handleContentScriptMessage({type, params, host}) {
|
||||
let level = await readPermissionLevel(host)
|
||||
|
||||
if (level >= PERMISSIONS_REQUIRED[type]) {
|
||||
// authorized, proceed
|
||||
} else {
|
||||
// ask for authorization
|
||||
try {
|
||||
await promptPermission(host, PERMISSIONS_REQUIRED[type])
|
||||
// authorized, proceed
|
||||
} catch (_) {
|
||||
// not authorized, stop here
|
||||
return {
|
||||
error: `insufficient permissions, required ${PERMISSIONS_REQUIRED[type]}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
switch (type) {
|
||||
@@ -38,4 +71,42 @@ browser.runtime.onMessage.addListener(async (req, sender) => {
|
||||
} catch (error) {
|
||||
return {error}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handlePromptMessage({id, condition, host, level}, sender) {
|
||||
switch (condition) {
|
||||
case 'forever':
|
||||
case 'expirable':
|
||||
prompts[id]?.resolve?.()
|
||||
updatePermission(host, {
|
||||
level,
|
||||
condition
|
||||
})
|
||||
break
|
||||
case 'single':
|
||||
prompts[id]?.resolve?.()
|
||||
break
|
||||
case 'no':
|
||||
prompts[id]?.reject?.()
|
||||
break
|
||||
}
|
||||
|
||||
delete prompts[id]
|
||||
browser.windows.remove(sender.tab.windowId)
|
||||
}
|
||||
|
||||
function promptPermission(host, level) {
|
||||
let id = Math.random().toString().slice(4)
|
||||
let qs = new URLSearchParams({host, level, id})
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
browser.windows.create({
|
||||
url: `${browser.runtime.getURL('prompt.html')}?${qs.toString()}`,
|
||||
type: 'popup',
|
||||
width: 340,
|
||||
height: 230
|
||||
})
|
||||
|
||||
prompts[id] = {resolve, reject}
|
||||
})
|
||||
}
|
||||
|
||||
74
extension/common.js
Normal file
74
extension/common.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import browser from 'webextension-polyfill'
|
||||
|
||||
export const PERMISSIONS_REQUIRED = {
|
||||
getPublicKey: 1,
|
||||
signEvent: 10
|
||||
}
|
||||
|
||||
const ORDERED_PERMISSIONS = [
|
||||
[1, ['getPublicKey']],
|
||||
[10, ['signEvent']]
|
||||
]
|
||||
|
||||
const PERMISSION_NAMES = {
|
||||
getPublicKey: 'read your public key',
|
||||
signEvent: 'sign events using your private key'
|
||||
}
|
||||
|
||||
export function getAllowedCapabilities(permission) {
|
||||
let requestedMethods = []
|
||||
for (let i = 0; i < ORDERED_PERMISSIONS.length; i++) {
|
||||
let [perm, methods] = ORDERED_PERMISSIONS[i]
|
||||
if (perm > permission) break
|
||||
requestedMethods = requestedMethods.concat(methods)
|
||||
}
|
||||
|
||||
if (requestedMethods.length === 0) return 'nothing'
|
||||
|
||||
return requestedMethods.map(method => PERMISSION_NAMES[method])
|
||||
}
|
||||
|
||||
export function getPermissionsString(permission) {
|
||||
let capabilities = getAllowedCapabilities(permission)
|
||||
|
||||
return (
|
||||
capabilities.slice(0, -1).join(', ') +
|
||||
' and ' +
|
||||
capabilities[capabilities.length - 1]
|
||||
)
|
||||
}
|
||||
|
||||
export async function readPermissions() {
|
||||
let {permissions = {}} = await browser.storage.local.get('permissions')
|
||||
|
||||
// delete expired
|
||||
var needsUpdate = false
|
||||
for (let host in permissions) {
|
||||
if (
|
||||
permissions[host].condition === 'expirable' &&
|
||||
permissions[host].created_at < Date.now() / 1000 - 5 * 60
|
||||
) {
|
||||
delete permissions[host]
|
||||
needsUpdate = true
|
||||
}
|
||||
}
|
||||
if (needsUpdate) browser.storage.local.set({permissions})
|
||||
|
||||
return permissions
|
||||
}
|
||||
|
||||
export async function readPermissionLevel(host) {
|
||||
return (await readPermissions())[host]?.level || 0
|
||||
}
|
||||
|
||||
export async function updatePermission(host, permission) {
|
||||
browser.storage.local.set({
|
||||
permissions: {
|
||||
...((await browser.storage.local.get('permissions').permissions) || {}),
|
||||
[host]: {
|
||||
...permission,
|
||||
created_at: Math.round(Date.now() / 1000)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -20,7 +20,7 @@ window.addEventListener('message', async message => {
|
||||
response = await browser.runtime.sendMessage({
|
||||
type: message.data.type,
|
||||
params: message.data.params,
|
||||
host: window.location.host
|
||||
host: location.host
|
||||
})
|
||||
} catch (error) {
|
||||
response = {error}
|
||||
|
||||
BIN
extension/icons/128x128.png
Normal file
BIN
extension/icons/128x128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
BIN
extension/icons/16x16.png
Normal file
BIN
extension/icons/16x16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
extension/icons/32x32.png
Normal file
BIN
extension/icons/32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
BIN
extension/icons/48x48.png
Normal file
BIN
extension/icons/48x48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"name": "nos2x",
|
||||
"description": "Nostr Signer Extension",
|
||||
"version": "0.0.1",
|
||||
"version": "1.0.0",
|
||||
"homepage_url": "https://github.com/fiatjaf/nos2x",
|
||||
"manifest_version": 2,
|
||||
"icons": {
|
||||
"16": "icons/16x16.png",
|
||||
"32": "icons/32x32.png",
|
||||
"48": "icons/48x48.png",
|
||||
"128": "icons/128x128.png"
|
||||
},
|
||||
"options_page": "options.html",
|
||||
"background": {
|
||||
"scripts": [
|
||||
@@ -10,6 +17,10 @@
|
||||
],
|
||||
"persistent": false
|
||||
},
|
||||
"browser_action": {
|
||||
"default_title": "nos2x",
|
||||
"default_popup": "popup.html"
|
||||
},
|
||||
"content_scripts": [{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": [
|
||||
|
||||
@@ -40,7 +40,7 @@ window.addEventListener('message', message => {
|
||||
|
||||
if (message.data.response.error) {
|
||||
window.nostr._requests[message.data.id].reject(
|
||||
new Error(`nos2x returned an error: ${message.data.response.error}`)
|
||||
new Error(`nos2x: ${message.data.response.error}`)
|
||||
)
|
||||
} else {
|
||||
window.nostr._requests[message.data.id].resolve(message.data.response)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,14 +3,17 @@
|
||||
<meta charset="utf-8" />
|
||||
<title>nos2x</title>
|
||||
|
||||
<h1>nos2x</h1>
|
||||
<p>nostr signer extension</p>
|
||||
|
||||
<label>
|
||||
private key:
|
||||
<input id="privateKeyInput" />
|
||||
</label>
|
||||
|
||||
<div id="message"></div>
|
||||
<div id="main" />
|
||||
|
||||
<script src="options.build.js"></script>
|
||||
|
||||
<style>
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
border: 1px solid;
|
||||
padding: 1px 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
/* global document */
|
||||
|
||||
import browser from 'webextension-polyfill'
|
||||
|
||||
document
|
||||
.getElementById('privateKeyInput')
|
||||
.addEventListener('input', async ev => {
|
||||
let key = document
|
||||
.getElementById('privateKeyInput')
|
||||
.value.toLowerCase()
|
||||
.trim()
|
||||
if (!key.match(/^[a-f0-9]{64}$/)) return
|
||||
|
||||
try {
|
||||
await browser.storage.local.set({
|
||||
private_key: key
|
||||
})
|
||||
showMessage('saved!')
|
||||
} catch (err) {
|
||||
showMessage(`error! ${err}`)
|
||||
}
|
||||
})
|
||||
|
||||
browser.storage.local.get('private_key').then(results => {
|
||||
document.getElementById('privateKeyInput').value = results.private_key
|
||||
})
|
||||
|
||||
function showMessage(str) {
|
||||
document.getElementById('message').innerHTML = str
|
||||
setTimeout(() => {
|
||||
document.getElementById('message').innerHTML = ''
|
||||
}, 5000)
|
||||
}
|
||||
91
extension/options.jsx
Normal file
91
extension/options.jsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import browser from 'webextension-polyfill'
|
||||
import React, {useState, useEffect} from 'react'
|
||||
import {render} from 'react-dom'
|
||||
|
||||
import {getPermissionsString, readPermissions} from './common'
|
||||
|
||||
function Options() {
|
||||
let [key, setKey] = useState('')
|
||||
let [permissions, setPermissions] = useState()
|
||||
let [message, setMessage] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
browser.storage.local.get(['private_key']).then(results => {
|
||||
if (results.private_key) setKey(results.private_key)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
readPermissions().then(permissions => {
|
||||
setPermissions(
|
||||
Object.entries(permissions).map(
|
||||
([host, {level, condition, created_at}]) => ({
|
||||
host,
|
||||
level,
|
||||
condition,
|
||||
created_at
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>nos2x</h1>
|
||||
<p>nostr signer extension</p>
|
||||
<h2>options</h2>
|
||||
<label>
|
||||
private key:
|
||||
<input value={key} onChange={handleKeyChange} />
|
||||
</label>
|
||||
{permissions?.length > 0 && (
|
||||
<>
|
||||
<h2>permissions</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>domain</th>
|
||||
<th>permissions</th>
|
||||
<th>condition</th>
|
||||
<th>since</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{permissions.map(({host, level, condition, created_at}) => (
|
||||
<tr key={host}>
|
||||
<td>{host}</td>
|
||||
<td>{getPermissionsString(level)}</td>
|
||||
<td>{condition}</td>
|
||||
<td>
|
||||
{new Date(created_at * 1000)
|
||||
.toISOString()
|
||||
.split('.')[0]
|
||||
.split('T')
|
||||
.join(' ')}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
<div>{message}</div>
|
||||
</>
|
||||
)
|
||||
|
||||
async function handleKeyChange(e) {
|
||||
let key = e.target.value.toLowerCase().trim()
|
||||
setKey(key)
|
||||
|
||||
if (key.match(/^[a-f0-9]{64}$/) || key === '') {
|
||||
await browser.storage.local.set({
|
||||
private_key: key
|
||||
})
|
||||
setMessage('saved!')
|
||||
setTimeout(setMessage, 3000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render(<Options />, document.getElementById('main'))
|
||||
8
extension/popup.html
Normal file
8
extension/popup.html
Normal file
@@ -0,0 +1,8 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<meta charset="utf-8" />
|
||||
<title>nos2x</title>
|
||||
|
||||
<div id="main" />
|
||||
|
||||
<script src="popup.build.js"></script>
|
||||
45
extension/popup.jsx
Normal file
45
extension/popup.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import browser from 'webextension-polyfill'
|
||||
import {Buffer} from 'buffer'
|
||||
import {render} from 'react-dom'
|
||||
import {getPublicKey} from 'nostr-tools'
|
||||
import React, {useState, useEffect} from 'react'
|
||||
|
||||
function Popup() {
|
||||
let [key, setKey] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
browser.storage.local.get('private_key').then(results => {
|
||||
if (results.private_key) {
|
||||
setKey(Buffer.from(getPublicKey(results.private_key)).toString('hex'))
|
||||
} else {
|
||||
setKey(null)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>nos2x</h2>
|
||||
{key === null ? (
|
||||
<p style={{width: '150px'}}>
|
||||
you don't have a private key set. use the options page to set one.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p>your public key:</p>
|
||||
<pre
|
||||
style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
width: '100px'
|
||||
}}
|
||||
>
|
||||
<code>{key}</code>
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
render(<Popup />, document.getElementById('main'))
|
||||
8
extension/prompt.html
Normal file
8
extension/prompt.html
Normal file
@@ -0,0 +1,8 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<meta charset="utf-8" />
|
||||
<title>nos2x</title>
|
||||
|
||||
<div id="main" style="width: 300px; height: 200px; margin: auto" />
|
||||
|
||||
<script src="prompt.build.js"></script>
|
||||
71
extension/prompt.jsx
Normal file
71
extension/prompt.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import browser from 'webextension-polyfill'
|
||||
import {render} from 'react-dom'
|
||||
import React from 'react'
|
||||
|
||||
import {getAllowedCapabilities} from './common'
|
||||
|
||||
function Prompt() {
|
||||
let qs = new URLSearchParams(location.search)
|
||||
let id = qs.get('id')
|
||||
let host = qs.get('host')
|
||||
let level = parseInt(qs.get('level'))
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<b style={{display: 'block', textAlign: 'center', fontSize: '200%'}}>
|
||||
{host}
|
||||
</b>{' '}
|
||||
<p>is requesting your permission to </p>
|
||||
<ul>
|
||||
{getAllowedCapabilities(level).map(cap => (
|
||||
<li key={cap}>
|
||||
<i style={{fontSize: '140%'}}>{cap}</i>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-around'
|
||||
}}
|
||||
>
|
||||
<button
|
||||
style={{marginTop: '5px'}}
|
||||
onClick={authorizeHandler('forever')}
|
||||
>
|
||||
authorize forever
|
||||
</button>
|
||||
<button
|
||||
style={{marginTop: '5px'}}
|
||||
onClick={authorizeHandler('expirable')}
|
||||
>
|
||||
authorize for 5 minutes
|
||||
</button>
|
||||
<button style={{marginTop: '5px'}} onClick={authorizeHandler('single')}>
|
||||
authorize just this
|
||||
</button>
|
||||
<button style={{marginTop: '5px'}} onClick={authorizeHandler('no')}>
|
||||
cancel
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
function authorizeHandler(condition) {
|
||||
return function (ev) {
|
||||
ev.preventDefault()
|
||||
browser.runtime.sendMessage({
|
||||
prompt: true,
|
||||
id,
|
||||
host,
|
||||
level,
|
||||
condition
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render(<Prompt />, document.getElementById('main'))
|
||||
Reference in New Issue
Block a user