working v1.

This commit is contained in:
fiatjaf
2022-01-14 09:49:03 -03:00
parent 0d38bc7eb3
commit db20f0c370
21 changed files with 884 additions and 1111 deletions

View File

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

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
extension/icons/48x48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -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": [

View File

@@ -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

View File

@@ -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>

View File

@@ -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
View 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:&nbsp;
<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
View 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
View 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
View 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
View 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'))