1046 lines
29 KiB
TypeScript
1046 lines
29 KiB
TypeScript
import { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools'
|
|
import { DbApp, dbi, DbKey, DbPending, DbPerm } from './db'
|
|
import { Keys } from './keys'
|
|
import NDK, {
|
|
IEventHandlingStrategy,
|
|
NDKEvent,
|
|
NDKNip46Backend,
|
|
NDKPrivateKeySigner,
|
|
NDKSigner,
|
|
} from '@nostr-dev-kit/ndk'
|
|
import { NOAUTHD_URL, WEB_PUSH_PUBKEY, NIP46_RELAYS, MIN_POW, MAX_POW, KIND_RPC, DOMAIN } from '../utils/consts'
|
|
import { Nip04 } from './nip04'
|
|
import { fetchNip05, getReqPerm, getShortenNpub, isPackagePerm } from '@/utils/helpers/helpers'
|
|
import { NostrPowEvent, minePow } from './pow'
|
|
//import { PrivateKeySigner } from './signer'
|
|
|
|
//const PERF_TEST = false
|
|
|
|
export interface KeyInfo {
|
|
npub: string
|
|
nip05?: string
|
|
locked: boolean
|
|
}
|
|
|
|
interface Key {
|
|
npub: string
|
|
ndk: NDK
|
|
backoff: number
|
|
signer: NDKSigner
|
|
backend: NDKNip46Backend
|
|
}
|
|
|
|
interface Pending {
|
|
req: DbPending
|
|
cb: (allow: boolean, remember: boolean, options?: any) => void
|
|
notified?: boolean
|
|
}
|
|
|
|
interface IAllowCallbackParams {
|
|
backend: NDKNip46Backend
|
|
npub: string
|
|
id: string
|
|
method: string
|
|
remotePubkey: string
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
params?: any
|
|
}
|
|
|
|
class Nip04KeyHandlingStrategy implements IEventHandlingStrategy {
|
|
private privkey: string
|
|
private nip04 = new Nip04()
|
|
|
|
constructor(privkey: string) {
|
|
this.privkey = privkey
|
|
}
|
|
|
|
private async getKey(backend: NDKNip46Backend, id: string, remotePubkey: string, recipientPubkey: string) {
|
|
if (
|
|
!(await backend.pubkeyAllowed({
|
|
id,
|
|
pubkey: remotePubkey,
|
|
// @ts-ignore
|
|
method: 'get_nip04_key',
|
|
params: recipientPubkey,
|
|
}))
|
|
) {
|
|
backend.debug(`get_nip04_key request from ${remotePubkey} rejected`)
|
|
return undefined
|
|
}
|
|
|
|
return Buffer.from(this.nip04.createKey(this.privkey, recipientPubkey)).toString('hex')
|
|
}
|
|
|
|
async handle(backend: NDKNip46Backend, id: string, remotePubkey: string, params: string[]) {
|
|
const [recipientPubkey] = params
|
|
return await this.getKey(backend, id, remotePubkey, recipientPubkey)
|
|
}
|
|
}
|
|
|
|
class EventHandlingStrategyWrapper implements IEventHandlingStrategy {
|
|
readonly backend: NDKNip46Backend
|
|
readonly npub: string
|
|
readonly method: string
|
|
private body: IEventHandlingStrategy
|
|
private allowCb: (params: IAllowCallbackParams) => Promise<boolean>
|
|
|
|
constructor(
|
|
backend: NDKNip46Backend,
|
|
npub: string,
|
|
method: string,
|
|
body: IEventHandlingStrategy,
|
|
allowCb: (params: IAllowCallbackParams) => Promise<boolean>
|
|
) {
|
|
this.backend = backend
|
|
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(Date.now(), 'handle', {
|
|
method: this.method,
|
|
id,
|
|
remotePubkey,
|
|
params,
|
|
})
|
|
const allow = await this.allowCb({
|
|
backend: this.backend,
|
|
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(Date.now(), '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 apps: DbApp[] = []
|
|
private doneReqIds: string[] = []
|
|
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('activate', event => event.waitUntil(swg.clients.claim()));
|
|
})
|
|
|
|
swg.addEventListener('install', (event) => {
|
|
console.log('install')
|
|
// swg.addEventListener('install', event => event.waitUntil(swg.skipWaiting()));
|
|
})
|
|
|
|
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)
|
|
// FIXME find a client that has our
|
|
// key page
|
|
for (const client of clientList) {
|
|
console.log('client', client.url)
|
|
if (new URL(client.url).pathname === '/' && 'focus' in client) {
|
|
client.focus()
|
|
return
|
|
}
|
|
}
|
|
|
|
// confirm screen url
|
|
const req = event.notification.data.req
|
|
console.log('req', req)
|
|
// const url = `${self.swg.location.origin}/key/${req.npub}?confirm-connect=true&appNpub=${req.appNpub}&reqId=${req.id}`
|
|
const url = `${self.swg.location.origin}/key/${req.npub}`
|
|
self.swg.clients.openWindow(url)
|
|
})
|
|
)
|
|
}
|
|
},
|
|
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)
|
|
this.apps = await dbi.listApps()
|
|
console.log('started apps', this.apps)
|
|
|
|
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 fetchNpubName(npub: string) {
|
|
const url = `${NOAUTHD_URL}/name?npub=${npub}`
|
|
const r = await fetch(url)
|
|
const d = await r.json()
|
|
return d?.names?.length ? (d.names[0] as string) : ''
|
|
}
|
|
|
|
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)
|
|
const body = await r.json()
|
|
throw new Error('Failed to fetch ' + url, { cause: body })
|
|
}
|
|
|
|
return await r.json()
|
|
}
|
|
|
|
private async sendPostAuthd({
|
|
npub,
|
|
url,
|
|
method = 'GET',
|
|
body = '',
|
|
pow = 0,
|
|
}: {
|
|
npub: string
|
|
url: string
|
|
method: string
|
|
body: string
|
|
pow?: number
|
|
}) {
|
|
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)])
|
|
|
|
// generate pow on auth evevnt
|
|
if (pow) {
|
|
const start = Date.now()
|
|
const powEvent: NostrPowEvent = authEvent.rawEvent()
|
|
const minedEvent = minePow(powEvent, pow)
|
|
console.log('mined pow of', pow, 'in', Date.now() - start, 'ms', minedEvent)
|
|
authEvent.tags = minedEvent.tags
|
|
}
|
|
|
|
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 async sendNameToServer(npub: string, name: string) {
|
|
const body = JSON.stringify({
|
|
npub,
|
|
name,
|
|
})
|
|
|
|
const method = 'POST'
|
|
const url = `${NOAUTHD_URL}/name`
|
|
|
|
// mas pow should be 21 or something like that
|
|
let pow = MIN_POW
|
|
while (pow <= MAX_POW) {
|
|
console.log('Try name', name, 'pow', pow)
|
|
try {
|
|
return await this.sendPostAuthd({
|
|
npub,
|
|
url,
|
|
method,
|
|
body,
|
|
pow,
|
|
})
|
|
} catch (e: any) {
|
|
console.log('error', e.cause)
|
|
if (e.cause && e.cause.minPow > pow) pow = e.cause.minPow
|
|
else throw e
|
|
}
|
|
}
|
|
throw new Error('Too many requests, retry later')
|
|
}
|
|
|
|
private async sendTokenToServer(npub: string, token: string) {
|
|
const body = JSON.stringify({
|
|
npub,
|
|
token,
|
|
})
|
|
|
|
const method = 'POST'
|
|
const url = `${NOAUTHD_URL}/created`
|
|
|
|
return this.sendPostAuthd({
|
|
npub,
|
|
url,
|
|
method,
|
|
body,
|
|
})
|
|
}
|
|
|
|
private notify() {
|
|
// FIXME collect info from accessBuffer and confirmBuffer
|
|
// and update the notifications
|
|
|
|
for (const r of this.confirmBuffer) {
|
|
if (r.notified) continue
|
|
|
|
const key = this.keys.find((k) => k.npub === r.req.npub)
|
|
if (!key) continue
|
|
|
|
const app = this.apps.find((a) => a.appNpub === r.req.appNpub)
|
|
if (r.req.method !== 'connect' && !app) continue
|
|
|
|
// FIXME check
|
|
const icon = 'assets/icons/logo.svg'
|
|
|
|
const appName = app?.name || getShortenNpub(r.req.appNpub)
|
|
// FIXME load profile?
|
|
const keyName = getShortenNpub(r.req.npub)
|
|
|
|
const tag = 'confirm-' + r.req.appNpub
|
|
const allowAction = 'allow:' + r.req.id
|
|
const disallowAction = 'disallow:' + r.req.id
|
|
const data = { req: r.req }
|
|
|
|
if (r.req.method === 'connect') {
|
|
const title = `Connect with new app`
|
|
const body = `Allow app "${appName}" to connect to key "${keyName}"`
|
|
this.swg.registration.showNotification(title, {
|
|
body,
|
|
tag,
|
|
icon,
|
|
data,
|
|
actions: [
|
|
{
|
|
action: allowAction,
|
|
title: 'Connect',
|
|
},
|
|
{
|
|
action: disallowAction,
|
|
title: 'Ignore',
|
|
},
|
|
],
|
|
})
|
|
} else {
|
|
const title = `Permission request`
|
|
const body = `Allow "${r.req.method}" by "${appName}" to "${keyName}"`
|
|
this.swg.registration.showNotification(title, {
|
|
body,
|
|
tag,
|
|
icon,
|
|
data,
|
|
actions: [
|
|
{
|
|
action: allowAction,
|
|
title: 'Yes',
|
|
},
|
|
{
|
|
action: disallowAction,
|
|
title: 'No',
|
|
},
|
|
],
|
|
})
|
|
}
|
|
|
|
// mark
|
|
r.notified = true
|
|
}
|
|
|
|
if (this.notifCallback) this.notifCallback()
|
|
this.notifCallback = null
|
|
}
|
|
|
|
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({
|
|
name,
|
|
nsec,
|
|
existingName,
|
|
}: {
|
|
name: string
|
|
nsec?: string
|
|
existingName?: boolean
|
|
}): Promise<KeyInfo> {
|
|
// lowercase
|
|
name = name.trim().toLocaleLowerCase()
|
|
|
|
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)
|
|
// @ts-ignore
|
|
const dbKey: DbKey = { npub, name, enckey, localKey }
|
|
await dbi.addKey(dbKey)
|
|
this.enckeys.push(dbKey)
|
|
await this.startKey({ npub, sk })
|
|
|
|
// assign nip05 before adding the key
|
|
// FIXME set name to db and if this call to 'send' fails
|
|
// then retry later
|
|
if (!existingName && name && !name.includes('@')) {
|
|
console.log('adding key', npub, name)
|
|
await this.sendNameToServer(npub, name)
|
|
}
|
|
|
|
const sub = await this.swg.registration.pushManager.getSubscription()
|
|
if (sub) await this.sendSubscriptionToServer(npub, sub)
|
|
|
|
return this.keyInfo(dbKey)
|
|
}
|
|
|
|
private getPerm(req: DbPending): string {
|
|
const reqPerm = getReqPerm(req)
|
|
const appPerms = this.perms.filter((p) => p.npub === req.npub && p.appNpub === req.appNpub)
|
|
|
|
// exact match first
|
|
let perm = appPerms.find((p) => p.perm === reqPerm)
|
|
// non-exact next
|
|
if (!perm) perm = appPerms.find((p) => isPackagePerm(p.perm, reqPerm))
|
|
|
|
console.log('req', req, 'perm', reqPerm, 'value', perm, appPerms)
|
|
return perm?.value || ''
|
|
}
|
|
|
|
private async connectApp({
|
|
npub,
|
|
appNpub,
|
|
appUrl,
|
|
perms,
|
|
appName = '',
|
|
appIcon = ''
|
|
}: {
|
|
npub: string,
|
|
appNpub: string,
|
|
appUrl: string,
|
|
appName?: string,
|
|
appIcon?: string,
|
|
perms: string[]
|
|
}) {
|
|
|
|
await dbi.addApp({
|
|
appNpub: appNpub,
|
|
npub: npub,
|
|
timestamp: Date.now(),
|
|
name: appName,
|
|
icon: appIcon,
|
|
url: appUrl,
|
|
})
|
|
|
|
// reload
|
|
this.apps = await dbi.listApps()
|
|
|
|
// write new perms confirmed by user
|
|
for (const p of perms) {
|
|
await dbi.addPerm({
|
|
id: Math.random().toString(36).substring(7),
|
|
npub: npub,
|
|
appNpub: appNpub,
|
|
perm: p,
|
|
value: '1',
|
|
timestamp: Date.now(),
|
|
})
|
|
}
|
|
|
|
// reload
|
|
this.perms = await dbi.listPerms()
|
|
}
|
|
|
|
private async allowPermitCallback({
|
|
backend,
|
|
npub,
|
|
id,
|
|
method,
|
|
remotePubkey,
|
|
params,
|
|
}: IAllowCallbackParams): Promise<boolean> {
|
|
// same reqs usually come on reconnects
|
|
if (this.doneReqIds.includes(id)) {
|
|
console.log('request already done', id)
|
|
// FIXME maybe repeat the reply, but without the Notification?
|
|
return false
|
|
}
|
|
|
|
const appNpub = nip19.npubEncode(remotePubkey)
|
|
const connected = !!this.apps.find((a) => a.appNpub === appNpub)
|
|
if (!connected && method !== 'connect') {
|
|
console.log('ignoring request before connect', method, id, appNpub, npub)
|
|
return false
|
|
}
|
|
|
|
const req: DbPending = {
|
|
id,
|
|
npub,
|
|
appNpub,
|
|
method,
|
|
params: JSON.stringify(params),
|
|
timestamp: Date.now(),
|
|
}
|
|
|
|
const self = this
|
|
return new Promise(async (ok) => {
|
|
// called when it's decided whether to allow this or not
|
|
const onAllow = async (manual: boolean, allow: boolean, remember: boolean, options?: any) => {
|
|
// confirm
|
|
console.log(Date.now(), allow ? 'allowed' : 'disallowed', npub, method, options, params)
|
|
|
|
if (manual) {
|
|
await dbi.confirmPending(id, allow)
|
|
|
|
// add app on 'allow connect'
|
|
if (method === 'connect' && allow) {
|
|
// if (!(await dbi.getApp(req.appNpub))) {
|
|
await dbi.addApp({
|
|
appNpub: req.appNpub,
|
|
npub: req.npub,
|
|
timestamp: Date.now(),
|
|
name: '',
|
|
icon: '',
|
|
url: options.appUrl || '',
|
|
})
|
|
|
|
// reload
|
|
self.apps = await dbi.listApps()
|
|
}
|
|
} else {
|
|
// just send to db w/o waiting for it
|
|
dbi.addConfirmed({
|
|
...req,
|
|
allowed: allow,
|
|
})
|
|
}
|
|
|
|
// 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) {
|
|
let newPerms = [getReqPerm(req)]
|
|
if (allow && options && options.perms) newPerms = options.perms
|
|
|
|
// write new perms confirmed by user
|
|
for (const p of newPerms) {
|
|
await dbi.addPerm({
|
|
id: req.id,
|
|
npub: req.npub,
|
|
appNpub: req.appNpub,
|
|
perm: p,
|
|
value: allow ? '1' : '0',
|
|
timestamp: Date.now(),
|
|
})
|
|
}
|
|
|
|
// reload
|
|
this.perms = await dbi.listPerms()
|
|
|
|
// confirm pending requests that might now have
|
|
// the proper perms
|
|
const otherReqs = self.confirmBuffer.filter((r) => r.req.appNpub === req.appNpub)
|
|
console.log('updated perms', this.perms, 'otherReqs', otherReqs, 'connected', connected)
|
|
for (const r of otherReqs) {
|
|
let perm = this.getPerm(r.req)
|
|
if (perm) {
|
|
r.cb(perm === '1', false)
|
|
}
|
|
}
|
|
}
|
|
|
|
// notify UI that it was confirmed
|
|
// if (!PERF_TEST)
|
|
this.updateUI()
|
|
|
|
// return to let nip46 flow proceed
|
|
ok(allow)
|
|
}
|
|
|
|
// check perms
|
|
const perm = this.getPerm(req)
|
|
console.log(Date.now(), 'perm', req.id, perm)
|
|
|
|
// have perm?
|
|
if (perm) {
|
|
// reply immediately
|
|
onAllow(false, perm === '1', false)
|
|
} else {
|
|
// put pending req to db
|
|
await dbi.addPending(req)
|
|
|
|
// need manual confirmation
|
|
console.log('need confirm', req)
|
|
|
|
// put to a list of pending requests
|
|
this.confirmBuffer.push({
|
|
req,
|
|
cb: (allow, remember, options) => onAllow(true, allow, remember, options),
|
|
})
|
|
|
|
// OAuth flow
|
|
const confirmMethod = method === 'connect' ? 'confirm-connect' : 'confirm-event'
|
|
const authUrl = `${self.swg.location.origin}/key/${npub}?${confirmMethod}=true&appNpub=${appNpub}&reqId=${id}&popup=true`
|
|
console.log('sending authUrl', authUrl, 'for', req)
|
|
// NOTE: if you set 'Update on reload' in the Chrome SW console
|
|
// then this message will cause a new tab opened by the peer,
|
|
// which will cause SW (this code) to reload, to fetch
|
|
// the pending requests and to re-send this event,
|
|
// looping for 10 seconds (our request age threshold)
|
|
backend.rpc.sendResponse(id, remotePubkey, 'auth_url', KIND_RPC, authUrl)
|
|
|
|
// show notifs
|
|
// this.notify()
|
|
|
|
// notify main thread to ask for user concent
|
|
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) // PrivateKeySigner
|
|
const backend = new NDKNip46Backend(ndk, signer, () => Promise.resolve(true))
|
|
this.keys.push({ npub, backend, signer, ndk, backoff })
|
|
|
|
// new method
|
|
backend.handlers['get_nip04_key'] = new Nip04KeyHandlingStrategy(sk)
|
|
|
|
// assign our own permission callback
|
|
for (const method in backend.handlers) {
|
|
backend.handlers[method] = new EventHandlingStrategyWrapper(
|
|
backend,
|
|
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,
|
|
// @ts-ignore
|
|
localKey: info.localKey,
|
|
})
|
|
await this.startKey({ npub, sk })
|
|
}
|
|
|
|
private async generateKey(name: string) {
|
|
const k = await this.addKey({ name })
|
|
this.updateUI()
|
|
return k
|
|
}
|
|
|
|
private async redeemToken(npub: string, token: string) {
|
|
console.log('redeeming token', npub, token)
|
|
await this.sendTokenToServer(npub, token)
|
|
}
|
|
|
|
private async importKey(name: string, nsec: string) {
|
|
const k = await this.addKey({ name, nsec })
|
|
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,
|
|
// @ts-ignore
|
|
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, nip05: 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)
|
|
|
|
let name = ''
|
|
let existingName = true
|
|
// check name - user might have provided external nip05,
|
|
// or just his npub - we must fetch their name from our
|
|
// server, and if not exists - try to assign one
|
|
const npubName = await this.fetchNpubName(npub)
|
|
if (npubName) {
|
|
// already have name for this npub
|
|
console.log('existing npub name', npub, npubName)
|
|
name = npubName
|
|
} else if (nip05.includes('@')) {
|
|
// no name for them?
|
|
const [nip05name, domain] = nip05.split('@')
|
|
if (domain === DOMAIN) {
|
|
// wtf? how did we learn their npub if
|
|
// it's the name on our server but we can't fetch it?
|
|
console.log('existing name', nip05name)
|
|
name = nip05name
|
|
} else {
|
|
// try to take same name on our domain
|
|
existingName = false
|
|
name = nip05name
|
|
let takenName = await fetchNip05(`${name}@${DOMAIN}`)
|
|
if (takenName) {
|
|
// already taken? try name_domain as name
|
|
name = `${nip05name}_${domain}`
|
|
takenName = await fetchNip05(`${name}@${DOMAIN}`)
|
|
}
|
|
if (takenName) {
|
|
console.log('All names taken, leave without a name?')
|
|
name = ''
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log('fetch', { name, existingName })
|
|
|
|
// add new key
|
|
const nsec = await this.keysModule.decryptKeyPass({
|
|
pubkey,
|
|
enckey,
|
|
passphrase,
|
|
})
|
|
const k = await this.addKey({ name, nsec, existingName })
|
|
this.updateUI()
|
|
return k
|
|
}
|
|
|
|
private async confirm(id: string, allow: boolean, remember: boolean, options?: any) {
|
|
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 req', id, allow, remember, options)
|
|
req.cb(allow, remember, options)
|
|
}
|
|
}
|
|
|
|
private async deleteApp(appNpub: string) {
|
|
this.apps = this.apps.filter((a) => a.appNpub !== appNpub)
|
|
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(args[0])
|
|
} else if (method === 'redeemToken') {
|
|
result = await this.redeemToken(args[0], args[1])
|
|
} else if (method === 'importKey') {
|
|
result = await this.importKey(args[0], args[1])
|
|
} else if (method === 'saveKey') {
|
|
result = await this.saveKey(args[0], args[1])
|
|
} else if (method === 'fetchKey') {
|
|
result = await this.fetchKey(args[0], args[1], args[2])
|
|
} else if (method === 'confirm') {
|
|
result = await this.confirm(args[0], args[1], args[2], args[3])
|
|
} else if (method === 'connectApp') {
|
|
result = await this.connectApp(args[0])
|
|
} 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) {
|
|
console.log('backend error', e)
|
|
event.source.postMessage({
|
|
id,
|
|
error: e.toString(),
|
|
})
|
|
}
|
|
}
|
|
|
|
private async updateUI() {
|
|
const clients = await this.swg.clients.matchAll({
|
|
includeUncontrolled: true,
|
|
})
|
|
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
|
|
}
|
|
}
|