First commit
This commit is contained in:
parent
ecafc1f8ba
commit
6dde554865
5
.env
Normal file
5
.env
Normal file
@ -0,0 +1,5 @@
|
||||
# this is pubkey of noauthd.nostrapps.org,
|
||||
# change if you're using a different noauthd server
|
||||
REACT_APP_WEB_PUSH_PUBKEY=BNW_39YcKbV4KunFxFhvMW5JUs8AljfFnGUeZpaerO-gwCoWyQat5ol0xOGB8MLaqqCbz0iptd2Qv3SToSGynMk
|
||||
#REACT_APP_NOAUTHD_URL=http://localhost:8000
|
||||
REACT_APP_NOAUTHD_URL=https://noauthd.login.nostrapps.org
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Nostr.Band
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
23
README
Normal file
23
README
Normal file
@ -0,0 +1,23 @@
|
||||
Noauth - Nostr key manager
|
||||
--------------------------
|
||||
|
||||
THIS IS BETA SOFTWARE, DON'T USE WITH REAL KEYS!
|
||||
|
||||
This is a web-based nostr signer app, it uses nip46 signer
|
||||
running inside a service worker, if SW is not running -
|
||||
a noauthd server sends a push message and wakes SW up. Also,
|
||||
keys can be saved to server and fetched later in an end-to-end
|
||||
encrypted way. Keys are encrypted with user-defined password,
|
||||
a good key derivation function is used to resist brute force.
|
||||
|
||||
This app works in Chrome on desktop and Android out of the box,
|
||||
try it with snort.social (use bunker:/... string as 'login string').
|
||||
|
||||
On iOS web push notifications are still experimental, eventually
|
||||
it will work on iOS out of the box too.
|
||||
|
||||
It works across devices, but that's unreliable, especially if
|
||||
signer is on mobile - if smartphone is locked then service worker might
|
||||
not wake up. Thanks to cloud sync/recovery of keys users can import
|
||||
their keys into this app on every device and then it works well.
|
||||
|
25
config-overrides.js
Normal file
25
config-overrides.js
Normal file
@ -0,0 +1,25 @@
|
||||
const webpack = require('webpack');
|
||||
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin');
|
||||
|
||||
module.exports = function override(config) {
|
||||
const fallback = config.resolve.fallback || {};
|
||||
Object.assign(fallback, {
|
||||
"crypto": require.resolve("crypto-browserify"),
|
||||
"stream": require.resolve("stream-browserify"),
|
||||
"assert": require.resolve("assert"),
|
||||
"http": require.resolve("stream-http"),
|
||||
"https": require.resolve("https-browserify"),
|
||||
"os": require.resolve("os-browserify"),
|
||||
"url": require.resolve("url")
|
||||
})
|
||||
config.resolve.fallback = fallback;
|
||||
config.plugins = (config.plugins || []).concat([
|
||||
new webpack.ProvidePlugin({
|
||||
process: 'process/browser',
|
||||
Buffer: ['buffer', 'Buffer']
|
||||
})
|
||||
])
|
||||
// turns off the plugin that forbids importing from node_modules for the above-mentioned stuff
|
||||
config.resolve.plugins = config.resolve.plugins.filter(plugin => !(plugin instanceof ModuleScopePlugin));
|
||||
return config;
|
||||
}
|
20371
package-lock.json
generated
20371
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
31
package.json
31
package.json
@ -3,6 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@nostr-dev-kit/ndk": "^2.0.5",
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
@ -10,10 +11,13 @@
|
||||
"@types/node": "^17.0.45",
|
||||
"@types/react": "^18.2.38",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"crypto": "^1.0.1",
|
||||
"dexie": "^3.2.4",
|
||||
"nostr-tools": "^1.17.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"typescript": "^4.9.5",
|
||||
"typescript": "^5.3.2",
|
||||
"web-vitals": "^2.1.4",
|
||||
"workbox-background-sync": "^6.6.0",
|
||||
"workbox-broadcast-update": "^6.6.0",
|
||||
@ -28,11 +32,16 @@
|
||||
"workbox-strategies": "^6.6.0",
|
||||
"workbox-streams": "^6.6.0"
|
||||
},
|
||||
"overrides": {
|
||||
"react-scripts": {
|
||||
"typescript": "^5.3.2"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
"start": "react-app-rewired start",
|
||||
"build": "react-app-rewired build",
|
||||
"test": "react-app-rewired test",
|
||||
"eject": "react-app-rewired eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
@ -51,5 +60,17 @@
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"assert": "^2.1.0",
|
||||
"buffer": "^6.0.3",
|
||||
"crypto-browserify": "^3.12.0",
|
||||
"https-browserify": "^1.0.0",
|
||||
"os-browserify": "^0.3.0",
|
||||
"process": "^0.11.10",
|
||||
"react-app-rewired": "^2.2.1",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"stream-http": "^3.2.0",
|
||||
"url": "^0.11.3"
|
||||
}
|
||||
}
|
||||
|
@ -21,7 +21,7 @@
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>React App</title>
|
||||
<title>Noauth</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"short_name": "Noauth",
|
||||
"name": "Noauth Nostr key manager",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
|
35
src/App.css
35
src/App.css
@ -1,38 +1,3 @@
|
||||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
217
src/App.tsx
217
src/App.tsx
@ -1,24 +1,213 @@
|
||||
import React from 'react';
|
||||
import logo from './logo.svg';
|
||||
import './App.css';
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { DbApp, DbKey, DbPending, DbPerm, dbi } from './db';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { swicCall, swicOnRender } from './swic';
|
||||
import { NIP46_RELAYS } from './consts';
|
||||
|
||||
function App() {
|
||||
|
||||
const [render, setRender] = useState(0)
|
||||
const [keys, setKeys] = useState<DbKey[]>([])
|
||||
const [apps, setApps] = useState<DbApp[]>([])
|
||||
const [perms, setPerms] = useState<DbPerm[]>([])
|
||||
const [pending, setPending] = useState<DbPending[]>([])
|
||||
|
||||
const load = async () => {
|
||||
const keys = await dbi.listKeys()
|
||||
setKeys(keys)
|
||||
|
||||
const apps = await dbi.listApps()
|
||||
setApps(apps)
|
||||
|
||||
const perms = await dbi.listPerms()
|
||||
setPerms(perms)
|
||||
|
||||
const pending = await dbi.listPending()
|
||||
const firstPending = new Map<string, DbPending>()
|
||||
for (const p of pending) {
|
||||
if (firstPending.get(p.appNpub)) continue
|
||||
firstPending.set(p.appNpub, p)
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
setPending([...firstPending.values()])
|
||||
|
||||
// rerender
|
||||
setRender(r => r + 1)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [render])
|
||||
|
||||
async function log(s: string) {
|
||||
const log = document.getElementById('log')
|
||||
if (log) log.innerHTML = s
|
||||
}
|
||||
|
||||
async function askNotificationPermission() {
|
||||
return new Promise<void>((ok, rej) => {
|
||||
// Let's check if the browser supports notifications
|
||||
if (!("Notification" in window)) {
|
||||
log("This browser does not support notifications.")
|
||||
rej()
|
||||
} else {
|
||||
Notification.requestPermission().then(() => {
|
||||
log("notifications perm" + Notification.permission)
|
||||
if (Notification.permission === 'granted') ok()
|
||||
else rej()
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function enableNotifications() {
|
||||
await askNotificationPermission()
|
||||
try {
|
||||
const r = await swicCall('enablePush')
|
||||
if (!r) {
|
||||
log(`Failed to enable push subscription`)
|
||||
return
|
||||
}
|
||||
|
||||
log(`enabled!`)
|
||||
} catch (e) {
|
||||
log(`Error: ${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function call(cb: () => any) {
|
||||
try {
|
||||
return await cb()
|
||||
} catch (e) {
|
||||
log(`Error: ${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function generateKey() {
|
||||
call(async () => {
|
||||
const k: any = await swicCall('generateKey');
|
||||
log("New key " + k.npub)
|
||||
})
|
||||
}
|
||||
|
||||
async function confirmPending(id: string, allow: boolean, remember: boolean) {
|
||||
call(async () => {
|
||||
await swicCall('confirm', id, allow, remember);
|
||||
console.log("confirmed", id, allow, remember)
|
||||
})
|
||||
}
|
||||
|
||||
async function deleteApp(appNpub: string) {
|
||||
call(async () => {
|
||||
await swicCall('deleteApp', appNpub);
|
||||
log('App deleted')
|
||||
})
|
||||
}
|
||||
|
||||
async function deletePerm(id: string) {
|
||||
call(async () => {
|
||||
await swicCall('deletePerm', id);
|
||||
log('Perm deleted')
|
||||
})
|
||||
}
|
||||
|
||||
async function saveKey(npub: string) {
|
||||
call(async () => {
|
||||
// @ts-ignore
|
||||
const passphrase = document.getElementById(`passphrase${npub}`)?.value
|
||||
await swicCall('saveKey', npub, passphrase)
|
||||
log('Key saved')
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchNewKey() {
|
||||
call(async () => {
|
||||
// @ts-ignore
|
||||
const npub = document.getElementById('npub')?.value
|
||||
// @ts-ignore
|
||||
const passphrase = document.getElementById('passphrase')?.value
|
||||
console.log("fetch", npub, passphrase)
|
||||
const k: any = await swicCall('fetchKey', npub, passphrase)
|
||||
log("Fetched " + k.npub)
|
||||
})
|
||||
}
|
||||
|
||||
// subscribe to updates from the service worker
|
||||
swicOnRender(() => {
|
||||
setRender(r => r + 1)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
<header className="App-header">
|
||||
<img src={logo} className="App-logo" alt="logo" />
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to reload.
|
||||
</p>
|
||||
<a
|
||||
className="App-link"
|
||||
href="https://reactjs.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn React
|
||||
</a>
|
||||
Nostr Login
|
||||
</header>
|
||||
<div>
|
||||
<h4>Keys:</h4>
|
||||
{keys.map((k) => {
|
||||
const { data: pubkey } = nip19.decode(k.npub)
|
||||
const str = `bunker://${pubkey}?relay=${NIP46_RELAYS[0]}`
|
||||
return (
|
||||
<div key={k.npub} style={{ marginBottom: "10px" }}>
|
||||
{k.npub}
|
||||
<div>{str}</div>
|
||||
<div>
|
||||
<input id={`passphrase${k.npub}`} placeholder='save password' />
|
||||
<button onClick={() => saveKey(k.npub)}>save</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div>
|
||||
<button onClick={generateKey}>generate key</button>
|
||||
</div>
|
||||
<div>
|
||||
<input id='npub' placeholder='npub' />
|
||||
<input id='passphrase' placeholder='password' />
|
||||
<button onClick={fetchNewKey}>fetch key</button>
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
<h4>Connected apps:</h4>
|
||||
{apps.map((a) => (
|
||||
<div key={a.npub} style={{ marginTop: "10px" }}>
|
||||
<div>
|
||||
{a.npub} => {a.appNpub}
|
||||
<button onClick={() => deleteApp(a.appNpub)}>x</button>
|
||||
</div>
|
||||
<h5>Perms:</h5>
|
||||
{perms.filter(p => p.appNpub === a.appNpub).map(p => (
|
||||
<div key={p.id}>
|
||||
{p.perm}: {p.value}
|
||||
<button onClick={() => deletePerm(p.id)}>x</button>
|
||||
</div>
|
||||
))}
|
||||
<hr />
|
||||
</div>
|
||||
))}
|
||||
|
||||
<h4>Pending requests:</h4>
|
||||
{pending.map((p) => (
|
||||
<div key={p.id}>
|
||||
{p.appNpub} => {p.npub} ({p.method})
|
||||
<button onClick={() => confirmPending(p.id, true, false)}>yes</button>
|
||||
<button onClick={() => confirmPending(p.id, false, false)}>no</button>
|
||||
<button onClick={() => confirmPending(p.id, true, true)}>yes all</button>
|
||||
<button onClick={() => confirmPending(p.id, false, true)}>no all</button>
|
||||
</div>
|
||||
))}
|
||||
<hr />
|
||||
|
||||
<div>
|
||||
<button onClick={enableNotifications}>enable background signing</button>
|
||||
</div>
|
||||
<div>
|
||||
<textarea id='log'></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
694
src/backend.ts
Normal file
694
src/backend.ts
Normal file
@ -0,0 +1,694 @@
|
||||
import { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools'
|
||||
import { dbi, DbKey, DbPending, DbPerm } from './db'
|
||||
import { Keys } from './keys'
|
||||
import NDK, { IEventHandlingStrategy, NDKEvent, NDKNip46Backend, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk'
|
||||
import { NOAUTHD_URL, WEB_PUSH_PUBKEY, NIP46_RELAYS } from './consts'
|
||||
|
||||
export interface KeyInfo {
|
||||
npub: string
|
||||
nip05?: string
|
||||
locked: boolean
|
||||
}
|
||||
|
||||
interface Key {
|
||||
npub: string
|
||||
ndk: NDK
|
||||
backoff: number
|
||||
signer: NDKPrivateKeySigner
|
||||
backend: NDKNip46Backend
|
||||
}
|
||||
|
||||
interface Pending {
|
||||
req: DbPending
|
||||
cb: (allow: boolean, remember: boolean) => void
|
||||
}
|
||||
|
||||
interface IAllowCallbackParams {
|
||||
npub: string,
|
||||
id: string,
|
||||
method: string,
|
||||
remotePubkey: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
params?: any
|
||||
}
|
||||
|
||||
class EventHandlingStrategyWrapper implements IEventHandlingStrategy {
|
||||
readonly npub: string
|
||||
readonly method: string
|
||||
private body: IEventHandlingStrategy
|
||||
private allowCb: (params: IAllowCallbackParams) => Promise<boolean>
|
||||
|
||||
constructor(
|
||||
npub: string,
|
||||
method: string,
|
||||
body: IEventHandlingStrategy,
|
||||
allowCb: (params: IAllowCallbackParams) => Promise<boolean>
|
||||
) {
|
||||
this.npub = npub
|
||||
this.method = method
|
||||
this.body = body
|
||||
this.allowCb = allowCb
|
||||
}
|
||||
|
||||
async handle(
|
||||
backend: NDKNip46Backend,
|
||||
id: string,
|
||||
remotePubkey: string,
|
||||
params: string[]
|
||||
): Promise<string | undefined> {
|
||||
console.log("handle", { method: this.method, id, remotePubkey, params })
|
||||
const allow = await this.allowCb({
|
||||
npub: this.npub,
|
||||
id,
|
||||
method: this.method,
|
||||
remotePubkey,
|
||||
params
|
||||
})
|
||||
if (!allow) return undefined
|
||||
return this.body.handle(backend, id, remotePubkey, params)
|
||||
.then(r => {
|
||||
console.log("req", id, "method", this.method, "result", r)
|
||||
return r
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class NoauthBackend {
|
||||
readonly swg: ServiceWorkerGlobalScope
|
||||
private keysModule: Keys
|
||||
private enckeys: DbKey[] = []
|
||||
private keys: Key[] = []
|
||||
private perms: DbPerm[] = []
|
||||
private confirmBuffer: Pending[] = []
|
||||
private accessBuffer: DbPending[] = []
|
||||
private notifCallback: (() => void) | null = null
|
||||
|
||||
public constructor(swg: ServiceWorkerGlobalScope) {
|
||||
this.swg = swg
|
||||
this.keysModule = new Keys(swg.crypto.subtle)
|
||||
|
||||
const self = this
|
||||
swg.addEventListener('activate', (event) => {
|
||||
console.log("activate")
|
||||
})
|
||||
|
||||
swg.addEventListener('install', (event) => {
|
||||
console.log("install")
|
||||
})
|
||||
|
||||
swg.addEventListener('push', (event) => {
|
||||
console.log("got push", event)
|
||||
self.onPush(event)
|
||||
event.waitUntil(new Promise((ok: any) => {
|
||||
self.setNotifCallback(ok)
|
||||
}))
|
||||
})
|
||||
|
||||
swg.addEventListener('message', (event) => {
|
||||
self.onMessage(event)
|
||||
})
|
||||
|
||||
swg.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
if (event.action.startsWith("allow:")) {
|
||||
self.confirm(event.action.split(':')[1], true, false)
|
||||
} else if (event.action.startsWith("allow-remember:")) {
|
||||
self.confirm(event.action.split(':')[1], true, true)
|
||||
} else if (event.action.startsWith("disallow:")) {
|
||||
self.confirm(event.action.split(':')[1], false, false)
|
||||
} else {
|
||||
|
||||
event.waitUntil(
|
||||
self.swg.clients.matchAll({ type: "window" })
|
||||
.then((clientList) => {
|
||||
console.log("clients", clientList.length)
|
||||
for (const client of clientList) {
|
||||
console.log("client", client.url)
|
||||
if (new URL(client.url).pathname === "/" && "focus" in client)
|
||||
return client.focus();
|
||||
}
|
||||
// if (self.swg.clients.openWindow)
|
||||
// return self.swg.clients.openWindow("/");
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
false // ???
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
public async start() {
|
||||
this.enckeys = await dbi.listKeys()
|
||||
console.log("started encKeys", this.listKeys())
|
||||
this.perms = await dbi.listPerms()
|
||||
console.log("started perms", this.perms)
|
||||
|
||||
const sub = await this.swg.registration.pushManager.getSubscription()
|
||||
|
||||
for (const k of this.enckeys) {
|
||||
await this.unlock(k.npub)
|
||||
|
||||
// ensure we're subscribed on the server
|
||||
if (sub)
|
||||
await this.sendSubscriptionToServer(k.npub, sub)
|
||||
}
|
||||
}
|
||||
|
||||
public setNotifCallback(cb: () => void) {
|
||||
if (this.notifCallback) {
|
||||
this.notify()
|
||||
}
|
||||
this.notifCallback = cb
|
||||
}
|
||||
|
||||
public listKeys(): KeyInfo[] {
|
||||
return this.enckeys.map<KeyInfo>((k) => this.keyInfo(k))
|
||||
}
|
||||
|
||||
public isLocked(npub: string): boolean {
|
||||
return !this.keys.find(k => k.npub === npub)
|
||||
}
|
||||
|
||||
public hasKey(npub: string): boolean {
|
||||
return !!this.enckeys.find(k => k.npub === npub)
|
||||
}
|
||||
|
||||
private async sha256(s: string) {
|
||||
return Buffer.from(
|
||||
await this.swg.crypto.subtle.digest('SHA-256', Buffer.from(s)))
|
||||
.toString('hex')
|
||||
}
|
||||
|
||||
private async sendPost({
|
||||
url,
|
||||
method,
|
||||
headers,
|
||||
body
|
||||
}: {
|
||||
url: string,
|
||||
method: string,
|
||||
headers: any,
|
||||
body: string
|
||||
}) {
|
||||
const r = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers
|
||||
},
|
||||
body,
|
||||
})
|
||||
if (r.status !== 200 && r.status != 201) {
|
||||
console.log("Fetch error", url, method, r.status)
|
||||
throw new Error("Failed to fetch"+url)
|
||||
}
|
||||
|
||||
return await r.json();
|
||||
}
|
||||
|
||||
private async sendPostAuthd({
|
||||
npub,
|
||||
url,
|
||||
method = 'GET',
|
||||
body = ''
|
||||
}: {
|
||||
npub: string,
|
||||
url: string,
|
||||
method: string,
|
||||
body: string
|
||||
}) {
|
||||
|
||||
const { data: pubkey } = nip19.decode(npub)
|
||||
|
||||
const key = this.keys.find(k => k.npub === npub)
|
||||
if (!key) throw new Error("Unknown key")
|
||||
|
||||
const authEvent = new NDKEvent(key.ndk, {
|
||||
pubkey: pubkey as string,
|
||||
kind: 27235,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
content: '',
|
||||
tags: [
|
||||
['u', url],
|
||||
['method', method],
|
||||
]
|
||||
})
|
||||
if (body)
|
||||
authEvent.tags.push(['payload', await this.sha256(body)])
|
||||
|
||||
authEvent.sig = await authEvent.sign(key.signer)
|
||||
|
||||
const auth = this.swg.btoa(JSON.stringify(authEvent.rawEvent()))
|
||||
|
||||
return await this.sendPost({
|
||||
url,
|
||||
method,
|
||||
headers: {
|
||||
'Authorization': `Nostr ${auth}`,
|
||||
},
|
||||
body
|
||||
})
|
||||
}
|
||||
|
||||
private async sendSubscriptionToServer(
|
||||
npub: string,
|
||||
pushSubscription: PushSubscription
|
||||
) {
|
||||
|
||||
const body = JSON.stringify({
|
||||
npub,
|
||||
relays: NIP46_RELAYS,
|
||||
pushSubscription
|
||||
})
|
||||
|
||||
const method = 'POST'
|
||||
const url = `${NOAUTHD_URL}/subscribe`
|
||||
|
||||
return this.sendPostAuthd({
|
||||
npub,
|
||||
url,
|
||||
method,
|
||||
body
|
||||
})
|
||||
}
|
||||
private async sendKeyToServer(
|
||||
npub: string,
|
||||
enckey: string,
|
||||
pwh: string
|
||||
) {
|
||||
|
||||
const body = JSON.stringify({
|
||||
npub,
|
||||
data: enckey,
|
||||
pwh
|
||||
})
|
||||
|
||||
const method = 'POST'
|
||||
const url = `${NOAUTHD_URL}/put`
|
||||
|
||||
return this.sendPostAuthd({
|
||||
npub,
|
||||
url,
|
||||
method,
|
||||
body
|
||||
})
|
||||
}
|
||||
|
||||
private async fetchKeyFromServer(
|
||||
npub: string,
|
||||
pwh: string
|
||||
) {
|
||||
|
||||
const body = JSON.stringify({
|
||||
npub,
|
||||
pwh
|
||||
})
|
||||
|
||||
const method = 'POST'
|
||||
const url = `${NOAUTHD_URL}/get`
|
||||
|
||||
return await this.sendPost({
|
||||
url,
|
||||
method,
|
||||
headers: {},
|
||||
body
|
||||
})
|
||||
}
|
||||
|
||||
private notify() {
|
||||
// FIXME collect info from accessBuffer and confirmBuffer
|
||||
// and update the notifications
|
||||
|
||||
for (const r of this.confirmBuffer) {
|
||||
const text = `Confirm "${r.req.method}" by "${r.req.appNpub}"`
|
||||
this.swg.registration.showNotification('Signer access', {
|
||||
body: text,
|
||||
tag: "confirm-" + r.req.appNpub,
|
||||
actions: [
|
||||
{
|
||||
action: "allow:" + r.req.id,
|
||||
title: "Yes"
|
||||
},
|
||||
{
|
||||
action: "disallow:" + r.req.id,
|
||||
title: "No"
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
if (this.notifCallback)
|
||||
this.notifCallback()
|
||||
}
|
||||
|
||||
private keyInfo(k: DbKey): KeyInfo {
|
||||
return {
|
||||
npub: k.npub,
|
||||
nip05: k.nip05,
|
||||
locked: this.isLocked(k.npub)
|
||||
}
|
||||
}
|
||||
|
||||
private async generateGoodKey(): Promise<string> {
|
||||
return generatePrivateKey()
|
||||
}
|
||||
|
||||
public async addKey(nsec?: string): Promise<KeyInfo> {
|
||||
let sk = ''
|
||||
if (nsec) {
|
||||
const { type, data } = nip19.decode(nsec)
|
||||
if (type !== 'nsec') throw new Error('Bad nsec')
|
||||
sk = data
|
||||
} else {
|
||||
sk = await this.generateGoodKey()
|
||||
}
|
||||
const pubkey = getPublicKey(sk)
|
||||
const npub = nip19.npubEncode(pubkey)
|
||||
const localKey = await this.keysModule.generateLocalKey()
|
||||
const enckey = await this.keysModule.encryptKeyLocal(sk, localKey)
|
||||
const dbKey: DbKey = { npub, enckey, localKey }
|
||||
await dbi.addKey(dbKey)
|
||||
this.enckeys.push(dbKey)
|
||||
await this.startKey({ npub, sk })
|
||||
|
||||
const sub = await this.swg.registration.pushManager.getSubscription()
|
||||
if (sub)
|
||||
await this.sendSubscriptionToServer(npub, sub)
|
||||
|
||||
return this.keyInfo(dbKey)
|
||||
}
|
||||
|
||||
private getPerm(req: DbPending): string {
|
||||
return this.perms.find(p => p.npub === req.npub
|
||||
&& p.appNpub === req.appNpub
|
||||
&& p.perm === req.method)?.value || ''
|
||||
}
|
||||
|
||||
private async allowPermitCallback({
|
||||
npub,
|
||||
id,
|
||||
method,
|
||||
remotePubkey,
|
||||
params
|
||||
}: IAllowCallbackParams): Promise<boolean> {
|
||||
|
||||
const appNpub = nip19.npubEncode(remotePubkey)
|
||||
const req: DbPending = {
|
||||
id,
|
||||
npub,
|
||||
appNpub,
|
||||
method,
|
||||
params: JSON.stringify(params),
|
||||
timestamp: Date.now()
|
||||
}
|
||||
if (!await dbi.addPending(req)) {
|
||||
console.log("request already done", id)
|
||||
// FIXME maybe repeat the reply, but without the Notification?
|
||||
return false
|
||||
}
|
||||
|
||||
const self = this
|
||||
return new Promise((ok) => {
|
||||
|
||||
// called when it's decided whether to allow this or not
|
||||
const cb = async (allow: boolean, remember: boolean) => {
|
||||
|
||||
// confirm
|
||||
console.log(allow ? "allowed" : "disallowed", npub, method, params)
|
||||
await dbi.confirmPending(id, allow)
|
||||
|
||||
if (!await dbi.getApp(req.appNpub)) {
|
||||
await dbi.addApp({
|
||||
appNpub: req.appNpub,
|
||||
npub: req.npub,
|
||||
timestamp: Date.now(),
|
||||
name: '',
|
||||
icon: '',
|
||||
url: ''
|
||||
})
|
||||
}
|
||||
|
||||
// for notifications
|
||||
self.accessBuffer.push(req)
|
||||
|
||||
// clear from pending
|
||||
const index = self.confirmBuffer.findIndex(r => r.req.id === id)
|
||||
if (index >= 0)
|
||||
self.confirmBuffer.splice(index, 1)
|
||||
|
||||
if (remember) {
|
||||
await dbi.addPerm({
|
||||
id: req.id,
|
||||
npub: req.npub,
|
||||
appNpub: req.appNpub,
|
||||
perm: method,
|
||||
value: allow ? '1' : '0',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
this.perms = await dbi.listPerms()
|
||||
|
||||
const otherReqs = self.confirmBuffer.filter(r => r.req.appNpub === req.appNpub)
|
||||
for (const r of otherReqs) {
|
||||
if (r.req.method === req.method) {
|
||||
r.cb(allow, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// notify UI that it was confirmed
|
||||
this.updateUI()
|
||||
|
||||
// return to let nip46 flow proceed
|
||||
ok(allow)
|
||||
}
|
||||
|
||||
// check perms
|
||||
const perm = this.getPerm(req)
|
||||
console.log("perm", req.id, perm)
|
||||
|
||||
// have perm?
|
||||
if (perm) {
|
||||
// reply immediately
|
||||
cb(perm === '1', false)
|
||||
} else {
|
||||
|
||||
// need manual confirmation
|
||||
console.log("need confirm", req)
|
||||
|
||||
// put to a list of pending requests
|
||||
this.confirmBuffer.push({
|
||||
req,
|
||||
cb
|
||||
})
|
||||
|
||||
// show notifs
|
||||
this.notify()
|
||||
|
||||
// notify main thread to ask for user concent
|
||||
// FIXME show a 'confirm' notification?
|
||||
this.updateUI()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async startKey({ npub, sk, backoff = 1000 }: { npub: string, sk: string, backoff?: number }) {
|
||||
const ndk = new NDK({
|
||||
explicitRelayUrls: NIP46_RELAYS
|
||||
})
|
||||
|
||||
// init relay objects but dont wait until we connect
|
||||
ndk.connect()
|
||||
|
||||
const signer = new NDKPrivateKeySigner(sk)
|
||||
const backend = new NDKNip46Backend(ndk, sk, () => Promise.resolve(true))
|
||||
this.keys.push({ npub, backend, signer, ndk, backoff })
|
||||
|
||||
// assign our own permission callback
|
||||
for (const method in backend.handlers) {
|
||||
backend.handlers[method] = new EventHandlingStrategyWrapper(npub, method, backend.handlers[method], this.allowPermitCallback.bind(this))
|
||||
}
|
||||
|
||||
// start
|
||||
backend.start()
|
||||
console.log("started", npub)
|
||||
|
||||
// backoff reset on successfull connection
|
||||
const self = this
|
||||
const onConnect = () => {
|
||||
// reset backoff
|
||||
const key = self.keys.find(k => k.npub === npub)
|
||||
if (key) key.backoff = 0
|
||||
console.log("reset backoff for", npub)
|
||||
}
|
||||
|
||||
// reconnect handling
|
||||
let reconnected = false
|
||||
const onDisconnect = () => {
|
||||
if (reconnected) return
|
||||
if (ndk.pool.connectedRelays().length > 0) return
|
||||
reconnected = true
|
||||
console.log(new Date(), "all relays are down for key", npub)
|
||||
|
||||
// run full restart after a pause
|
||||
const bo = self.keys.find(k => k.npub === npub)?.backoff || 1000
|
||||
setTimeout(() => {
|
||||
console.log(new Date(), "reconnect relays for key", npub, "backoff", bo)
|
||||
// @ts-ignore
|
||||
for (const r of ndk.pool.relays.values())
|
||||
r.disconnect()
|
||||
// make sure it no longer activates
|
||||
backend.handlers = {}
|
||||
|
||||
self.keys = self.keys.filter(k => k.npub !== npub)
|
||||
self.startKey({ npub, sk, backoff: Math.min(bo * 2, 60000) })
|
||||
}, bo)
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
for (const r of ndk.pool.relays.values()) {
|
||||
r.on('connect', onConnect)
|
||||
r.on('disconnect', onDisconnect)
|
||||
}
|
||||
}
|
||||
|
||||
public async unlock(npub: string) {
|
||||
console.log("unlocking", npub)
|
||||
if (!this.isLocked(npub)) throw new Error(`Key ${npub} already unlocked`)
|
||||
const info = this.enckeys.find(k => k.npub === npub)
|
||||
if (!info) throw new Error(`Key ${npub} not found`)
|
||||
const { type } = nip19.decode(npub)
|
||||
if (type !== "npub") throw new Error(`Invalid npub ${npub}`)
|
||||
const sk = await this.keysModule.decryptKeyLocal({ enckey: info.enckey, localKey: info.localKey })
|
||||
await this.startKey({ npub, sk })
|
||||
}
|
||||
|
||||
private async generateKey() {
|
||||
const k = await this.addKey()
|
||||
this.updateUI()
|
||||
return k
|
||||
}
|
||||
|
||||
private async saveKey(npub: string, passphrase: string) {
|
||||
const info = this.enckeys.find(k => k.npub === npub)
|
||||
if (!info) throw new Error(`Key ${npub} not found`)
|
||||
const sk = await this.keysModule.decryptKeyLocal({ enckey: info.enckey, localKey: info.localKey })
|
||||
const { enckey, pwh } = await this.keysModule.encryptKeyPass({ key: sk, passphrase })
|
||||
await this.sendKeyToServer(npub, enckey, pwh)
|
||||
}
|
||||
|
||||
private async fetchKey(npub: string, passphrase: string) {
|
||||
const { type, data: pubkey } = nip19.decode(npub)
|
||||
if (type !== "npub") throw new Error(`Invalid npub ${npub}`)
|
||||
const { pwh } = await this.keysModule.generatePassKey(pubkey, passphrase)
|
||||
const { data: enckey } = await this.fetchKeyFromServer(npub, pwh);
|
||||
|
||||
// key already exists?
|
||||
const key = this.enckeys.find(k => k.npub === npub)
|
||||
if (key) return this.keyInfo(key)
|
||||
|
||||
// add new key
|
||||
const nsec = await this.keysModule.decryptKeyPass({ pubkey, enckey, passphrase })
|
||||
const k = await this.addKey(nsec)
|
||||
this.updateUI()
|
||||
return k
|
||||
}
|
||||
|
||||
private async confirm(id: string, allow: boolean, remember: boolean) {
|
||||
const req = this.confirmBuffer.find(r => r.req.id === id)
|
||||
if (!req) {
|
||||
console.log("req ", id, "not found")
|
||||
|
||||
await dbi.removePending(id)
|
||||
this.updateUI()
|
||||
} else {
|
||||
|
||||
console.log("confirming", id, allow, remember)
|
||||
req.cb(allow, remember)
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteApp(appNpub: string) {
|
||||
this.perms = this.perms.filter(p => p.appNpub !== appNpub)
|
||||
await dbi.removeApp(appNpub)
|
||||
await dbi.removeAppPerms(appNpub)
|
||||
this.updateUI()
|
||||
}
|
||||
|
||||
private async deletePerm(id: string) {
|
||||
this.perms = this.perms.filter(p => p.id !== id)
|
||||
await dbi.removePerm(id)
|
||||
this.updateUI()
|
||||
}
|
||||
|
||||
private async enablePush(): Promise<boolean> {
|
||||
const options = {
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: WEB_PUSH_PUBKEY,
|
||||
}
|
||||
|
||||
const pushSubscription = await this.swg.registration.pushManager.subscribe(options);
|
||||
console.log("push endpoint", JSON.stringify(pushSubscription));
|
||||
|
||||
if (!pushSubscription) {
|
||||
console.log("failed to enable push subscription")
|
||||
return false
|
||||
}
|
||||
|
||||
// subscribe to all pubkeys
|
||||
for (const k of this.keys) {
|
||||
await this.sendSubscriptionToServer(k.npub, pushSubscription);
|
||||
}
|
||||
console.log("push enabled")
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
public async onMessage(event: any) {
|
||||
const { id, method, args } = event.data
|
||||
try {
|
||||
//console.log("UI message", id, method, args)
|
||||
let result = undefined
|
||||
if (method === 'generateKey') {
|
||||
result = await this.generateKey()
|
||||
} else if (method === 'saveKey') {
|
||||
result = await this.saveKey(args[0], args[1])
|
||||
} else if (method === 'fetchKey') {
|
||||
result = await this.fetchKey(args[0], args[1])
|
||||
} else if (method === 'confirm') {
|
||||
result = await this.confirm(args[0], args[1], args[2])
|
||||
} else if (method === 'deleteApp') {
|
||||
result = await this.deleteApp(args[0])
|
||||
} else if (method === 'deletePerm') {
|
||||
result = await this.deletePerm(args[0])
|
||||
} else if (method === 'enablePush') {
|
||||
result = await this.enablePush()
|
||||
} else {
|
||||
console.log("unknown method from UI ", method)
|
||||
}
|
||||
event.source.postMessage({
|
||||
id, result
|
||||
})
|
||||
} catch (e: any) {
|
||||
event.source.postMessage({
|
||||
id, error: e.toString()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async updateUI() {
|
||||
const clients = await this.swg.clients.matchAll()
|
||||
console.log("updateUI clients", clients.length)
|
||||
for (const client of clients) {
|
||||
client.postMessage({})
|
||||
}
|
||||
}
|
||||
|
||||
public async onPush(event: any) {
|
||||
console.log("push", { data: event.data });
|
||||
// noop - we just need browser to launch this worker
|
||||
// FIXME use event.waitUntil and and unblock after we
|
||||
// show a notification
|
||||
}
|
||||
}
|
4
src/consts.ts
Normal file
4
src/consts.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export const NIP46_RELAYS = ['wss://relay.login.nostrapps.org']
|
||||
export const NOAUTHD_URL = process.env.REACT_APP_NOAUTHD_URL
|
||||
export const WEB_PUSH_PUBKEY = process.env.REACT_APP_WEB_PUSH_PUBKEY
|
||||
|
190
src/db.ts
Normal file
190
src/db.ts
Normal file
@ -0,0 +1,190 @@
|
||||
import Dexie from 'dexie'
|
||||
|
||||
export interface DbKey {
|
||||
npub: string
|
||||
nip05?: string
|
||||
name?: string
|
||||
avatar?: string
|
||||
relays?: string[]
|
||||
enckey: string
|
||||
localKey: CryptoKey
|
||||
}
|
||||
|
||||
export interface DbApp {
|
||||
appNpub: string
|
||||
npub: string
|
||||
name: string
|
||||
icon: string
|
||||
url: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface DbPerm {
|
||||
id: string
|
||||
npub: string
|
||||
appNpub: string
|
||||
perm: string
|
||||
value: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface DbPending {
|
||||
id: string
|
||||
npub: string
|
||||
appNpub: string
|
||||
timestamp: number
|
||||
method: string
|
||||
params: string
|
||||
}
|
||||
|
||||
export interface DbHistory {
|
||||
id: string
|
||||
npub: string
|
||||
appNpub: string
|
||||
timestamp: number
|
||||
method: string
|
||||
params: string
|
||||
allowed: boolean
|
||||
}
|
||||
|
||||
export interface DbSchema extends Dexie {
|
||||
keys: Dexie.Table<DbKey, string>
|
||||
apps: Dexie.Table<DbApp, string>
|
||||
perms: Dexie.Table<DbPerm, string>
|
||||
pending: Dexie.Table<DbPending, string>
|
||||
history: Dexie.Table<DbHistory, string>
|
||||
}
|
||||
|
||||
export const db = new Dexie('noauthdb') as DbSchema
|
||||
|
||||
db.version(5).stores({
|
||||
keys: 'npub',
|
||||
apps: 'appNpub,npub,name,timestamp',
|
||||
perms: 'id,npub,appNpub,perm,value,timestamp',
|
||||
pending: 'id,npub,appNpub,timestamp,method',
|
||||
history: 'id,npub,appNpub,timestamp,method,allowed',
|
||||
requestHistory: 'id'
|
||||
})
|
||||
|
||||
export const dbi = {
|
||||
addKey: async (key: DbKey) => {
|
||||
try {
|
||||
await db.keys.add(key)
|
||||
} catch (error) {
|
||||
console.log(`db addKey error: ${error}`)
|
||||
}
|
||||
},
|
||||
listKeys: async (): Promise<DbKey[]> => {
|
||||
try {
|
||||
return await db.keys.toArray()
|
||||
} catch (error) {
|
||||
console.log(`db listKeys error: ${error}`)
|
||||
return []
|
||||
}
|
||||
},
|
||||
getApp: async (appNpub: string) => {
|
||||
try {
|
||||
return await db.apps.get(appNpub)
|
||||
} catch (error) {
|
||||
console.log(`db getApp error: ${error}`)
|
||||
}
|
||||
},
|
||||
addApp: async (app: DbApp) => {
|
||||
try {
|
||||
await db.apps.add(app)
|
||||
} catch (error) {
|
||||
console.log(`db addApp error: ${error}`)
|
||||
}
|
||||
},
|
||||
listApps: async (): Promise<DbApp[]> => {
|
||||
try {
|
||||
return await db.apps.toArray()
|
||||
} catch (error) {
|
||||
console.log(`db listApps error: ${error}`)
|
||||
return []
|
||||
}
|
||||
},
|
||||
removeApp: async (appNpub: string) => {
|
||||
try {
|
||||
return await db.apps.delete(appNpub)
|
||||
} catch (error) {
|
||||
console.log(`db removeApp error: ${error}`)
|
||||
}
|
||||
},
|
||||
addPerm: async (perm: DbPerm) => {
|
||||
try {
|
||||
await db.perms.add(perm)
|
||||
} catch (error) {
|
||||
console.log(`db addPerm error: ${error}`)
|
||||
}
|
||||
},
|
||||
listPerms: async (): Promise<DbPerm[]> => {
|
||||
try {
|
||||
return await db.perms.toArray()
|
||||
} catch (error) {
|
||||
console.log(`db listPerms error: ${error}`)
|
||||
return []
|
||||
}
|
||||
},
|
||||
removePerm: async (id: string) => {
|
||||
try {
|
||||
return await db.perms.delete(id)
|
||||
} catch (error) {
|
||||
console.log(`db removePerm error: ${error}`)
|
||||
}
|
||||
},
|
||||
removeAppPerms: async (appNpub: string) => {
|
||||
try {
|
||||
return await db.perms.where({ appNpub }).delete()
|
||||
} catch (error) {
|
||||
console.log(`db removeAppPerms error: ${error}`)
|
||||
}
|
||||
},
|
||||
addPending: async (r: DbPending) => {
|
||||
try {
|
||||
return db.transaction('rw', db.pending, db.history, async () => {
|
||||
const exists = (await db.pending.where('id').equals(r.id).toArray()).length > 0
|
||||
|| (await db.history.where('id').equals(r.id).toArray()).length > 0
|
||||
if (exists) return false
|
||||
|
||||
await db.pending.add(r)
|
||||
return true
|
||||
})
|
||||
} catch (error) {
|
||||
console.log(`db addPending error: ${error}`)
|
||||
return false
|
||||
}
|
||||
},
|
||||
removePending: async (id: string) => {
|
||||
try {
|
||||
return await db.pending.delete(id)
|
||||
} catch (error) {
|
||||
console.log(`db removePending error: ${error}`)
|
||||
}
|
||||
},
|
||||
listPending: async (): Promise<DbPending[]> => {
|
||||
try {
|
||||
return await db.pending.toArray()
|
||||
} catch (error) {
|
||||
console.log(`db listPending error: ${error}`)
|
||||
return []
|
||||
}
|
||||
},
|
||||
confirmPending: async (id: string, allowed: boolean) => {
|
||||
try {
|
||||
db.transaction('rw', db.pending, db.history, async () => {
|
||||
const r: DbPending | undefined
|
||||
= await db.pending.where('id').equals(id).first()
|
||||
if (!r) throw new Error("Pending not found " + id)
|
||||
const h: DbHistory = {
|
||||
...r,
|
||||
allowed
|
||||
}
|
||||
await db.pending.delete(id)
|
||||
await db.history.add(h)
|
||||
})
|
||||
} catch (error) {
|
||||
console.log(`db addPending error: ${error}`)
|
||||
}
|
||||
},
|
||||
}
|
@ -2,8 +2,8 @@ import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import * as serviceWorkerRegistration from './serviceWorkerRegistration';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
import { swicRegister } from './swic';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
@ -17,7 +17,7 @@ root.render(
|
||||
// If you want your app to work offline and load faster, you can change
|
||||
// unregister() to register() below. Note this comes with some pitfalls.
|
||||
// Learn more about service workers: https://cra.link/PWA
|
||||
serviceWorkerRegistration.unregister();
|
||||
swicRegister()
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
|
136
src/keys.ts
Normal file
136
src/keys.ts
Normal file
@ -0,0 +1,136 @@
|
||||
import crypto, { pbkdf2 } from 'crypto';
|
||||
import { getPublicKey, nip19 } from 'nostr-tools';
|
||||
|
||||
// encrypted keys have a prefix and version
|
||||
// so that we'd be able to switch to a better
|
||||
// implementation eventually, like when scrypt/argon
|
||||
// are better supported
|
||||
const PREFIX = 'noauth'
|
||||
const PREFIX_LOCAL = 'noauthl'
|
||||
const VERSION = '1'
|
||||
const VERSION_LOCAL = '1'
|
||||
|
||||
// v1 params
|
||||
// key derivation
|
||||
const ITERATIONS = 10000000
|
||||
const ITERATIONS_PWH = 100000
|
||||
const HASH_SIZE = 32
|
||||
const HASH_ALGO = 'sha256'
|
||||
// encryption
|
||||
const ALGO = 'aes-256-cbc';
|
||||
const IV_SIZE = 16
|
||||
|
||||
// valid passwords are a limited ASCII only, see notes below
|
||||
const ASCII_REGEX = /^[A-Za-z0-9!@#$%^&*()]{4,}$/
|
||||
|
||||
const ALGO_LOCAL = 'AES-CBC';
|
||||
const KEY_SIZE_LOCAL = 256;
|
||||
|
||||
export class Keys {
|
||||
subtle: any
|
||||
|
||||
constructor(cryptoSubtle: any) {
|
||||
this.subtle = cryptoSubtle
|
||||
}
|
||||
|
||||
public isValidPassphase(passphrase: string): boolean {
|
||||
return ASCII_REGEX.test(passphrase)
|
||||
}
|
||||
|
||||
public async generatePassKey(pubkey: string, passphrase: string)
|
||||
: Promise<{ passkey: Buffer, pwh: string }> {
|
||||
|
||||
const salt = Buffer.from(pubkey, 'hex')
|
||||
|
||||
// https://nodejs.org/api/crypto.html#using-strings-as-inputs-to-cryptographic-apis
|
||||
// https://github.com/ricmoo/scrypt-js#encoding-notes
|
||||
// We could use string.normalize() to make sure all JS implementations
|
||||
// are compatible, but since we're looking to make this thing a standard
|
||||
// then the simplest way is to exclude unicode and only work with ASCII
|
||||
if (!this.isValidPassphase(passphrase)) throw new Error("Password must be 4+ ASCII chars")
|
||||
|
||||
return new Promise((ok, fail) => {
|
||||
// NOTE: we should use Argon2 or scrypt later, for now
|
||||
// let's start with a widespread and natively-supported pbkdf2
|
||||
pbkdf2(passphrase, salt, ITERATIONS, HASH_SIZE, HASH_ALGO, (err, key) => {
|
||||
if (err) fail(err)
|
||||
else {
|
||||
pbkdf2(key, passphrase, ITERATIONS_PWH, HASH_SIZE, HASH_ALGO, (err, hash) => {
|
||||
if (err) fail(err)
|
||||
else ok({ passkey: key, pwh: hash.toString('hex') })
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
public async generateLocalKey(): Promise<CryptoKey> {
|
||||
return await this.subtle.generateKey(
|
||||
{ name: ALGO_LOCAL, length: KEY_SIZE_LOCAL },
|
||||
// NOTE: important to make sure it's not visible in
|
||||
// dev console in IndexedDB
|
||||
/*extractable*/false,
|
||||
["encrypt", "decrypt"]
|
||||
)
|
||||
}
|
||||
|
||||
public async encryptKeyLocal(key: string, localKey: CryptoKey): Promise<string> {
|
||||
const nsec = nip19.nsecEncode(key)
|
||||
const iv = crypto.randomBytes(IV_SIZE)
|
||||
const encrypted = await this.subtle.encrypt({ name: ALGO_LOCAL, iv }, localKey, Buffer.from(nsec))
|
||||
return `${PREFIX_LOCAL}:${VERSION_LOCAL}:${iv.toString('hex')}:${Buffer.from(encrypted).toString('hex')}}`
|
||||
}
|
||||
|
||||
public async decryptKeyLocal({ enckey, localKey }: { enckey: string, localKey: CryptoKey }): Promise<string> {
|
||||
const parts = enckey.split(':')
|
||||
if (parts.length !== 4) throw new Error("Bad encrypted key")
|
||||
if (parts[0] !== PREFIX_LOCAL) throw new Error("Bad encrypted key prefix")
|
||||
if (parts[1] !== VERSION_LOCAL) throw new Error("Bad encrypted key version")
|
||||
if (parts[2].length !== IV_SIZE * 2) throw new Error("Bad encrypted key iv")
|
||||
if (parts[3].length < 30) throw new Error("Bad encrypted key data")
|
||||
const iv = Buffer.from(parts[2], 'hex');
|
||||
const data = Buffer.from(parts[3], 'hex');
|
||||
const decrypted = await this.subtle.decrypt({ name: ALGO_LOCAL, iv }, localKey, data)
|
||||
const { type, data: value } = nip19.decode(Buffer.from(decrypted).toString())
|
||||
if (type !== "nsec") throw new Error("Bad encrypted key payload type")
|
||||
if ((value as string).length !== 64) throw new Error("Bad encrypted key payload length")
|
||||
return (value as string)
|
||||
}
|
||||
|
||||
public async encryptKeyPass({ key, passphrase }: { key: string, passphrase: string })
|
||||
: Promise<{ enckey: string, pwh: string }> {
|
||||
const start = Date.now()
|
||||
const nsec = nip19.nsecEncode(key)
|
||||
const pubkey = getPublicKey(key)
|
||||
const { passkey, pwh } = await this.generatePassKey(pubkey, passphrase)
|
||||
const iv = crypto.randomBytes(IV_SIZE)
|
||||
const cipher = crypto.createCipheriv(ALGO, passkey, iv)
|
||||
const encrypted = Buffer.concat([cipher.update(nsec), cipher.final()])
|
||||
console.log("encrypted key in ", Date.now() - start)
|
||||
return {
|
||||
enckey: `${PREFIX}:${VERSION}:${iv.toString('hex')}:${encrypted.toString('hex')}}`,
|
||||
pwh
|
||||
}
|
||||
}
|
||||
|
||||
public async decryptKeyPass({ pubkey, enckey, passphrase }: { pubkey: string, enckey: string, passphrase: string }): Promise<string> {
|
||||
const start = Date.now()
|
||||
const parts = enckey.split(':')
|
||||
if (parts.length !== 4) throw new Error("Bad encrypted key")
|
||||
if (parts[0] !== PREFIX) throw new Error("Bad encrypted key prefix")
|
||||
if (parts[1] !== VERSION) throw new Error("Bad encrypted key version")
|
||||
if (parts[2].length !== IV_SIZE * 2) throw new Error("Bad encrypted key iv")
|
||||
if (parts[3].length < 30) throw new Error("Bad encrypted key data")
|
||||
const { passkey } = await this.generatePassKey(pubkey, passphrase)
|
||||
const iv = Buffer.from(parts[2], 'hex')
|
||||
const data = Buffer.from(parts[3], 'hex')
|
||||
const decipher = crypto.createDecipheriv(ALGO, passkey, iv)
|
||||
const decrypted = Buffer.concat([decipher.update(data), decipher.final()])
|
||||
const nsec = decrypted.toString()
|
||||
const { type, data: value } = nip19.decode(nsec)
|
||||
if (type !== "nsec") throw new Error("Bad encrypted key payload type")
|
||||
if (value.length !== 64) throw new Error("Bad encrypted key payload length")
|
||||
console.log("decrypted key in ", Date.now() - start)
|
||||
return nsec;
|
||||
}
|
||||
}
|
@ -13,6 +13,7 @@ import { ExpirationPlugin } from 'workbox-expiration';
|
||||
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
|
||||
import { registerRoute } from 'workbox-routing';
|
||||
import { StaleWhileRevalidate } from 'workbox-strategies';
|
||||
import { NoauthBackend } from './backend';
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope;
|
||||
|
||||
@ -78,3 +79,12 @@ self.addEventListener('message', (event) => {
|
||||
});
|
||||
|
||||
// Any other custom service worker logic can go here.
|
||||
|
||||
async function start() {
|
||||
console.log("worker starting")
|
||||
const backend = new NoauthBackend(self)
|
||||
await backend.start()
|
||||
}
|
||||
|
||||
start()
|
||||
|
||||
|
@ -21,6 +21,7 @@ const isLocalhost = Boolean(
|
||||
type Config = {
|
||||
onSuccess?: (registration: ServiceWorkerRegistration) => void;
|
||||
onUpdate?: (registration: ServiceWorkerRegistration) => void;
|
||||
onError?: (e: any) => void;
|
||||
};
|
||||
|
||||
export function register(config?: Config) {
|
||||
@ -31,6 +32,9 @@ export function register(config?: Config) {
|
||||
// Our service worker won't work if PUBLIC_URL is on a different origin
|
||||
// from what our page is served on. This might happen if a CDN is used to
|
||||
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
|
||||
if (config && config.onError) {
|
||||
config.onError(new Error("Wrong origin"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@ -98,6 +102,9 @@ function registerValidSW(swUrl: string, config?: Config) {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error during service worker registration:', error);
|
||||
if (config && config.onError) {
|
||||
config.onError(new Error(`Install error: ${error}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
70
src/swic.ts
Normal file
70
src/swic.ts
Normal file
@ -0,0 +1,70 @@
|
||||
// service-worker client interface
|
||||
import * as serviceWorkerRegistration from './serviceWorkerRegistration';
|
||||
|
||||
let swr: ServiceWorkerRegistration | null = null
|
||||
const reqs = new Map<number,{ ok: (r: any) => void, rej: (r: any) => void }>()
|
||||
let nextReqId = 1
|
||||
let onRender: (() => void) | null = null
|
||||
|
||||
export async function swicRegister() {
|
||||
serviceWorkerRegistration.register({
|
||||
onSuccess(registration) {
|
||||
console.log("sw registered")
|
||||
swr = registration
|
||||
},
|
||||
onError(e) {
|
||||
console.log(`error ${e}`)
|
||||
}
|
||||
});
|
||||
|
||||
navigator.serviceWorker.ready.then(r => swr = r)
|
||||
|
||||
navigator.serviceWorker.addEventListener('message', (event) => {
|
||||
onMessage((event as MessageEvent).data)
|
||||
})
|
||||
}
|
||||
|
||||
function onMessage(data: any) {
|
||||
const { id, result, error } = data
|
||||
console.log("SW message", id, result, error)
|
||||
|
||||
if (!id) {
|
||||
if (onRender) onRender()
|
||||
return
|
||||
}
|
||||
|
||||
const r = reqs.get(id)
|
||||
if (!r) {
|
||||
console.log("Unexpected message from service worker", data)
|
||||
return
|
||||
}
|
||||
|
||||
reqs.delete(id)
|
||||
if (error) r.rej(error)
|
||||
else r.ok(result)
|
||||
}
|
||||
|
||||
export async function swicCall(method: string, ...args: any[]) {
|
||||
const id = nextReqId
|
||||
nextReqId++
|
||||
|
||||
return new Promise((ok, rej) => {
|
||||
if (!swr || !swr.active) {
|
||||
rej(new Error("No active service worker"))
|
||||
return
|
||||
}
|
||||
|
||||
reqs.set(id, { ok, rej })
|
||||
const msg = {
|
||||
id,
|
||||
method,
|
||||
args: [...args],
|
||||
}
|
||||
//console.log("sending to SW", msg)
|
||||
swr.active.postMessage(msg)
|
||||
})
|
||||
}
|
||||
|
||||
export function swicOnRender(cb: () => void) {
|
||||
onRender = cb
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user