diff --git a/packages/lib/src/Tools.ts b/packages/lib/src/Tools.ts index 6daf1d0..b026161 100644 --- a/packages/lib/src/Tools.ts +++ b/packages/lib/src/Tools.ts @@ -131,6 +131,43 @@ export const tryCatch = async (f: () => Promise | T | void, onError return undefined } +export const equals = (a: any, b: any) => { + if (a === b) return true + + if (Array.isArray(a)) { + if (!Array.isArray(b) || a.length !== b.length) { + return false + } + + for (let i = 0; i < a.length; i++) { + if (!equals(a[i], b[i])) { + return false + } + } + + return true + } + + if (typeof a === 'object') { + const aKeys = Object.keys(a) + const bKeys = Object.keys(b) + + if (typeof b !== 'object' || aKeys.length !== bKeys.length) { + return false + } + + for (const k of aKeys) { + if (!equals(a[k], b[k])) { + return false + } + } + + return true + } + + return false +} + // Curried utils export const nth = (i: number) => (xs: T[], ...args: unknown[]) => xs[i] diff --git a/packages/signer/.eslintignore b/packages/signer/.eslintignore new file mode 100644 index 0000000..43e824a --- /dev/null +++ b/packages/signer/.eslintignore @@ -0,0 +1,2 @@ +build +normalize-url diff --git a/packages/signer/README.md b/packages/signer/README.md new file mode 100644 index 0000000..b0db427 --- /dev/null +++ b/packages/signer/README.md @@ -0,0 +1,3 @@ +# @welshman/store [![version](https://badgen.net/npm/v/@welshman/store)](https://npmjs.com/package/@welshman/store) + +Utilities for dealing with svelte stores when using welshman. diff --git a/packages/signer/package.json b/packages/signer/package.json new file mode 100644 index 0000000..01483df --- /dev/null +++ b/packages/signer/package.json @@ -0,0 +1,33 @@ +{ + "name": "@welshman/signer", + "version": "0.0.1", + "author": "hodlbod", + "license": "MIT", + "description": "A nostr signer implemenation supporting several login methods.", + "publishConfig": { + "access": "public" + }, + "type": "module", + "files": [ + "build" + ], + "types": "./build/src/index.d.ts", + "exports": { + ".": { + "types": "./build/src/index.d.ts", + "import": "./build/src/index.mjs", + "require": "./build/src/index.cjs" + } + }, + "scripts": { + "pub": "npm run lint && npm run build && npm publish", + "build": "gts clean && tsc-multi", + "lint": "gts lint", + "fix": "gts fix" + }, + "devDependencies": { + "gts": "^5.0.1", + "tsc-multi": "^1.1.0", + "typescript": "~5.1.6" + } +} diff --git a/packages/signer/src/index.ts b/packages/signer/src/index.ts new file mode 100644 index 0000000..98f8bfc --- /dev/null +++ b/packages/signer/src/index.ts @@ -0,0 +1,5 @@ +export * from './util' +export * from './wrapper' +export * from './signers/secret' +export * from './signers/connect' +export * from './signers/extension' diff --git a/packages/signer/src/signers/connect.ts b/packages/signer/src/signers/connect.ts new file mode 100644 index 0000000..b438d5a --- /dev/null +++ b/packages/signer/src/signers/connect.ts @@ -0,0 +1,184 @@ +import {nip04, finalizeEvent, getPublicKey} from "nostr-tools" +import {hexToBytes} from '@noble/hashes/utils' +import {Emitter, tryCatch, randomId, sleep, equals, now} from "@welshman/lib" +import {createEvent, TrustedEvent, EventTemplate, NOSTR_CONNECT} from "@welshman/util" +import {subscribe, publish, Subscription} from "@welshman/net" +import {ISigner, decrypt} from '../util' +import {SecretSigner} from './secret' + +export type NostrConnectHandler = { + pubkey: string + relays: string[] + domain?: string +} + +export type ConnectResponse = { + id: string + error?: string + result?: string +} + +let singleton: NostrConnectBroker + +// FIXME set the full list of requested perms +const Perms = + "nip04_encrypt,nip04_decrypt,sign_event:0,sign_event:1,sign_event:4,sign_event:6,sign_event:7" + +export class NostrConnectBroker extends Emitter { + #sub: Subscription + #signer: ISigner + #ready = sleep(500) + #closed = false + #connectResult: any + + static get(pubkey: string, secret: string, handler: NostrConnectHandler) { + if ( + singleton?.pubkey !== pubkey || + singleton?.secret !== secret || + !equals(singleton?.handler, handler) + ) { + singleton?.teardown() + singleton = new NostrConnectBroker(pubkey, secret, handler) + } + + return singleton + } + + constructor( + readonly pubkey: string, + readonly secret: string, + readonly handler: NostrConnectHandler, + ) { + super() + + this.#signer = new SecretSigner(secret) + this.#sub = this.subscribe() + } + + subscribe() { + const sub = subscribe({ + relays: this.handler.relays, + filters: [ + { + since: now() - 30, + kinds: [NOSTR_CONNECT], + "#p": [getPublicKey(hexToBytes(this.secret))], + }, + ], + }) + + sub.emitter.on("event", async (url: string, e: TrustedEvent) => { + const json = await decrypt(this.#signer, e.pubkey, e.content) + const res = await tryCatch(() => JSON.parse(json)) + + if (!res.id) { + console.error(`Invalid nostr-connect response: ${json}`) + } + + if (res.result === "auth_url") { + this.emit(`auth-${res.id}`, res) + } else { + this.emit(`res-${res.id}`, res) + } + }) + + sub.emitter.on("complete", () => { + if (!this.#closed) { + this.#sub = this.subscribe() + } + }) + + return sub + } + + async request(method: string, params: string[], admin = false) { + // nsecbunker has a race condition + await this.#ready + + const id = randomId() + const pubkey = admin ? this.handler.pubkey : this.pubkey + const payload = JSON.stringify({id, method, params}) + const content = await nip04.encrypt(this.secret, pubkey, payload) + const template = createEvent(NOSTR_CONNECT, {content, tags: [["p", pubkey]]}) + const event = finalizeEvent(template, this.secret as any) + + publish({event, relays: this.handler.relays}) + + this.once(`auth-${id}`, res => { + window.open(res.result, "Coracle", "width=600,height=800,popup=yes") + }) + + return new Promise((resolve, reject) => { + this.once(`res-${id}`, ({result, error}: ConnectResponse) => { + if (error) { + reject(error as string) + } else { + resolve(result as string) + } + }) + }) + } + + createAccount(username: string) { + if (!this.handler.domain) { + throw new Error("Unable to create an account without a handler domain") + } + + return this.request("create_account", [username, this.handler.domain, "", Perms], true) + } + + async connect(token = "") { + if (!this.#connectResult) { + const params = [this.pubkey, token, Perms] + + this.#connectResult = await this.request("connect", params) + } + + return this.#connectResult === "ack" + } + + async signEvent(event: EventTemplate) { + return JSON.parse(await this.request("sign_event", [JSON.stringify(event)]) as string) + } + + nip04Encrypt(pk: string, message: string) { + return this.request("nip04_encrypt", [pk, message]) + } + + nip04Decrypt(pk: string, message: string) { + return this.request("nip04_decrypt", [pk, message]) + } + + nip44Encrypt(pk: string, message: string) { + return this.request("nip44_encrypt", [pk, message]) + } + + nip44Decrypt(pk: string, message: string) { + return this.request("nip44_decrypt", [pk, message]) + } + + teardown() { + this.#closed = true + this.#sub?.close() + } +} + +export class ConnectSigner implements ISigner { + constructor(private broker: NostrConnectBroker) {} + + isEnabled = () => true + + getPubkey = async () => this.broker.pubkey + + sign = (event: EventTemplate) => this.broker.signEvent(event) + + nip04 = { + encrypt: this.broker.nip04Encrypt, + decrypt: this.broker.nip04Decrypt, + } + + nip44 = { + encrypt: this.broker.nip44Encrypt, + decrypt: this.broker.nip44Decrypt, + } +} diff --git a/packages/signer/src/signers/extension.ts b/packages/signer/src/signers/extension.ts new file mode 100644 index 0000000..b9bc0a7 --- /dev/null +++ b/packages/signer/src/signers/extension.ts @@ -0,0 +1,59 @@ +import {EventTemplate} from '@welshman/util' +import {hash, own, Sign, ISigner, EncryptionImplementation} from '../util' + +export type Extension = { + sign: Sign + nip04: EncryptionImplementation + nip44: EncryptionImplementation + getPublicKey: () => string | undefined +} + +export class ExtensionSigner implements ISigner { + #lock = Promise.resolve() + + #ext = () => (window as {nostr?: Extension}).nostr + + #then = async (f: (ext: Extension) => T | Promise) => { + const promise = this.#lock.then(() => { + const ext = this.#ext() + + if (!ext) throw new Error("Extension is not enabled") + + return f(ext) + }) + + // Recover from errors + this.#lock = promise.then(() => undefined, () => undefined) + + return promise + } + + isEnabled = () => Boolean(this.#ext()) + + getPubkey = () => + this.#then(ext => { + const pubkey = ext.getPublicKey() + + if (!pubkey) throw new Error("Failed to retrieve pubkey") + + return pubkey as string + }) + + sign = (event: EventTemplate) => + this.#then(ext => ext.sign(hash(own(ext.getPublicKey() as string, event)))) + + nip04 = { + encrypt: (pubkey: string, message: string) => + this.#then(ext => ext.nip04.encrypt(pubkey, message)), + decrypt: (pubkey: string, message: string) => + this.#then(ext => ext.nip04.decrypt(pubkey, message)), + } + + nip44 = { + encrypt: (pubkey: string, message: string) => + this.#then(ext => ext.nip44.encrypt(pubkey, message)), + decrypt: (pubkey: string, message: string) => + this.#then(ext => ext.nip44.decrypt(pubkey, message)), + } +} + diff --git a/packages/signer/src/signers/secret.ts b/packages/signer/src/signers/secret.ts new file mode 100644 index 0000000..4dfee52 --- /dev/null +++ b/packages/signer/src/signers/secret.ts @@ -0,0 +1,30 @@ +import {EventTemplate} from '@welshman/util' +import {nip04, nip44, own, hash, sign, getPubkey, ISigner} from "../util" + +export class SecretSigner implements ISigner { + private pubkey: string + + constructor(private secret: string) { + this.pubkey = getPubkey(this.secret) + } + + isEnabled = () => true + + getPubkey = async () => this.pubkey + + sign = async (event: EventTemplate) => sign(hash(own(this.pubkey, event)), this.secret) + + nip04 = { + encrypt: async (pubkey: string, message: string) => + nip04.encrypt(pubkey, this.secret, message), + decrypt: async (pubkey: string, message: string) => + nip04.decrypt(pubkey, this.secret, message), + } + + nip44 = { + encrypt: async (pubkey: string, message: string) => + nip44.encrypt(pubkey, this.secret, message), + decrypt: async (pubkey: string, message: string) => + nip44.decrypt(pubkey, this.secret, message), + } +} diff --git a/packages/signer/src/util.ts b/packages/signer/src/util.ts new file mode 100644 index 0000000..7e1e472 --- /dev/null +++ b/packages/signer/src/util.ts @@ -0,0 +1,60 @@ +import {schnorr} from '@noble/curves/secp256k1' +import {bytesToHex, hexToBytes} from '@noble/hashes/utils' +import {nip04 as nt04, nip44 as nt44, generateSecretKey, getPublicKey, getEventHash} from "nostr-tools" +import {cached} from '@welshman/lib' +import {SignedEvent, HashedEvent, EventTemplate, OwnedEvent} from '@welshman/util' + +export const getSecret = () => bytesToHex(generateSecretKey()) + +export const getPubkey = (secret: string) => getPublicKey(hexToBytes(secret)) + +export const getHash = (event: OwnedEvent) => getEventHash(event) + +export const getSig = (event: HashedEvent, secret: string) => + bytesToHex(schnorr.sign(event.id, secret)) + +export const own = (pubkey: string, event: EventTemplate) => ({...event, pubkey}) + +export const hash = (event: OwnedEvent): HashedEvent => ({...event, id: getHash(event)}) + +export const sign = (event: HashedEvent, secret: string) => ({...event, sig: getSig(event, secret)}) + +export const nip04 = { + detect: (m: string) => m.includes("?iv="), + encrypt: (pubkey: string, secret: string, m: string) => nt04.encrypt(secret, pubkey, m), + decrypt: (pubkey: string, secret: string, m: string) => nt04.decrypt(secret, pubkey, m), +} + +export const nip44 = { + getSharedSecret: cached({ + maxSize: 10000, + getKey: ([secret, pubkey]) => [secret, pubkey].join(":"), + getValue: ([secret, pubkey]: string[]) => nt44.v2.utils.getConversationKey(hexToBytes(secret), pubkey), + }), + encrypt: (pubkey: string, secret: string, m: string) => nt44.v2.encrypt(m, nip44.getSharedSecret(secret, pubkey)), + decrypt: (pubkey: string, secret: string, m: string) => nt44.v2.decrypt(m, nip44.getSharedSecret(secret, pubkey)), +} + +export type Sign = (event: EventTemplate) => Promise + +export type Encrypt = (pubkey: string, message: string) => Promise + +export type Decrypt = (pubkey: string, message: string) => Promise + +export type EncryptionImplementation = { + encrypt: Encrypt + decrypt: Decrypt +} + +export interface ISigner { + isEnabled: () => boolean + getPubkey: () => Promise + sign: Sign + nip04: EncryptionImplementation + nip44: EncryptionImplementation +} + +export const decrypt = async (signer: ISigner, pubkey: string, message: string) => + nip04.detect(message) + ? signer.nip04.decrypt(pubkey, message) + : signer.nip44.decrypt(pubkey, message) diff --git a/packages/signer/src/wrapper.ts b/packages/signer/src/wrapper.ts new file mode 100644 index 0000000..c105aa6 --- /dev/null +++ b/packages/signer/src/wrapper.ts @@ -0,0 +1,77 @@ +import {UnwrappedEvent, SignedEvent, HashedEvent, EventTemplate, WRAP, SEAL} from '@welshman/util' +import {own, hash, decrypt, ISigner} from './util' + +// Wrapper + +export type WrapperParams = { + author?: string + wrap?: { + author: string + recipient: string + tags?: string[][] + } +} + +export class Wrapper { + seen = new Map() + + constructor(readonly userSigner: ISigner, readonly wrapSigner: ISigner) {} + + now = (drift = 0) => + Math.round(Date.now() / 1000 - Math.random() * Math.pow(10, drift)) + + getSeal = async (pk: string, rumor: HashedEvent) => + this.userSigner.sign(hash({ + kind: SEAL, + pubkey: await this.userSigner.getPubkey(), + content: await this.userSigner.nip44.encrypt(pk, JSON.stringify(rumor)), + created_at: this.now(5), + tags: [], + })) + + getWrap = async (pk: string, seal: SignedEvent) => + this.wrapSigner.sign(hash({ + kind: WRAP, + pubkey: await this.wrapSigner.getPubkey(), + content: await this.wrapSigner.nip44.encrypt(pk, JSON.stringify(seal)), + created_at: this.now(5), + tags: [["p", pk]], + })) + + wrap = async (pk: string, template: EventTemplate) => { + const pubkey = await this.userSigner.getPubkey() + const rumor = hash(own(pubkey, template)) + const seal = await this.getSeal(pk, rumor) + const wrap = await this.getWrap(pk, seal) + + return wrap + } + + unwrap = async (wrap: SignedEvent) => { + // Avoid decrypting the same event multiple times + if (this.seen.has(wrap.id)) { + const rumorOrError = this.seen.get(wrap.id) + + if (rumorOrError instanceof Error) { + throw rumorOrError + } else { + return rumorOrError + } + } + + try { + const seal = JSON.parse(await decrypt(this.wrapSigner, wrap.pubkey, wrap.content)) + const rumor = JSON.parse(await decrypt(this.wrapSigner, seal.pubkey, seal.content)) + + if (seal.pubkey !== rumor.pubkey) throw new Error("Seal pubkey does not match rumor pubkey") + + this.seen.set(wrap.id, rumor) + + return rumor + } catch (error) { + this.seen.set(wrap.id, error as Error) + + throw error + } + } +} diff --git a/packages/signer/tsc-multi.json b/packages/signer/tsc-multi.json new file mode 100644 index 0000000..dd7b078 --- /dev/null +++ b/packages/signer/tsc-multi.json @@ -0,0 +1,6 @@ +{ + "targets": [ + {"extname": ".mjs", "module": "esnext", "moduleResolution": "node"} + ], + "projects": ["tsconfig.json"] +} diff --git a/packages/signer/tsconfig.json b/packages/signer/tsconfig.json new file mode 100644 index 0000000..15d351a --- /dev/null +++ b/packages/signer/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../node_modules/gts/tsconfig-google.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "build", + "esModuleInterop": true, + "skipLibCheck": true, + "lib": ["esnext", "dom", "dom.iterable"] + }, + "include": ["**/*.ts"] +}