Files
noauth/src/modules/backend.ts

926 lines
21 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 } from '../utils/consts'
import { Nip04 } from './nip04'
import { getReqPerm, getShortenNpub, isPackagePerm } from '@/utils/helpers/helpers'
//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 {
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 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(Date.now(), '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(
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('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)
// 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 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) {
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 use Nsec.app icon!
const icon = 'https://nostr.band/android-chrome-192x192.png'
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()
}
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)
// @ts-ignore
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 {
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 allowPermitCallback({
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 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)
if (!(method === 'connect' && !allow)) {
// only add app if it's not 'disallow connect'
if (!(await dbi.getApp(req.appNpub))) {
await dbi.addApp({
appNpub: req.appNpub,
npub: req.npub,
timestamp: Date.now(),
name: '',
icon: '',
url: '',
})
// reload
self.apps = await dbi.listApps()
}
}
} else {
// just send to db w/o waiting for it
// if (!PERF_TEST)
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 perm = getReqPerm(req)
if (allow && options && options.perm) perm = options.perm
await dbi.addPerm({
id: req.id,
npub: req.npub,
appNpub: req.appNpub,
perm,
value: allow ? '1' : '0',
timestamp: Date.now(),
})
this.perms = await dbi.listPerms()
const otherReqs = self.confirmBuffer.filter(
(r) => r.req.appNpub === req.appNpub,
)
console.log(
'updated perms',
this.perms,
'otherReqs',
otherReqs,
)
for (const r of otherReqs) {
const perm = this.getPerm(r.req)
// if (r.req.method === req.method) {
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),
})
// 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) // PrivateKeySigner
const backend = new NDKNip46Backend(ndk, sk, () =>
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(
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() {
const k = await this.addKey()
this.updateUI()
return k
}
private async importKey(nsec: string) {
const k = await this.addKey(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) {
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,
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()
} else if (method === 'importKey') {
result = await this.importKey(args[0])
} 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], args[3])
} 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
}
}