diff --git a/packages/lib/src/Tools.ts b/packages/lib/src/Tools.ts index b026161..76f35d6 100644 --- a/packages/lib/src/Tools.ts +++ b/packages/lib/src/Tools.ts @@ -121,9 +121,15 @@ export const toggle = (x: T, xs: T[]) => xs.includes(x) ? remove(x, xs) : app export const clamp = ([min, max]: [number, number], n: number) => Math.min(max, Math.max(min, n)) -export const tryCatch = async (f: () => Promise | T | void, onError?: (e: Error) => void): Promise => { +export const tryCatch = (f: () => T, onError?: (e: Error) => void): T | undefined => { try { - return await f() + const r = f() + + if (r instanceof Promise) { + r.catch(e => onError?.(e as Error)) + } + + return r } catch (e) { onError?.(e as Error) } diff --git a/packages/signer/src/index.ts b/packages/signer/src/index.ts index 98f8bfc..0feb721 100644 --- a/packages/signer/src/index.ts +++ b/packages/signer/src/index.ts @@ -1,5 +1,5 @@ export * from './util' -export * from './wrapper' -export * from './signers/secret' -export * from './signers/connect' -export * from './signers/extension' +export * from './nip59' +export * from './signers/nip01' +export * from './signers/nip07' +export * from './signers/nip46' diff --git a/packages/signer/src/nip59.ts b/packages/signer/src/nip59.ts new file mode 100644 index 0000000..1bbec07 --- /dev/null +++ b/packages/signer/src/nip59.ts @@ -0,0 +1,85 @@ +import {UnwrappedEvent, SignedEvent, HashedEvent, EventTemplate, WRAP, SEAL} from '@welshman/util' +import {own, hash, decrypt, ISigner} from './util' +import {Nip01Signer} from './signers/nip01' + +export const seen = new Map() + +export const now = (drift = 0) => + Math.round(Date.now() / 1000 - Math.random() * Math.pow(10, drift)) + +export const getRumor = async (signer: ISigner, template: EventTemplate) => + hash(own(await signer.getPubkey(), template)) + +export const getSeal = async (signer: ISigner, pubkey: string, rumor: HashedEvent) => + signer.sign(hash({ + kind: SEAL, + pubkey: await signer.getPubkey(), + content: await signer.nip44.encrypt(pubkey, JSON.stringify(rumor)), + created_at: now(5), + tags: [], + })) + +export const getWrap = async (wrapper: ISigner, pubkey: string, seal: SignedEvent, tags: string[][]) => + wrapper.sign(hash({ + kind: WRAP, + pubkey: await wrapper.getPubkey(), + content: await wrapper.nip44.encrypt(pubkey, JSON.stringify(seal)), + created_at: now(5), + tags: [...tags, ["p", pubkey]], + })) + +export const wrap = async (signer: ISigner, wrapper: ISigner, pubkey: string, template: EventTemplate, tags: string[][] = []) => { + const rumor = await getRumor(signer, template) + const seal = await getSeal(signer, pubkey, rumor) + const wrap = await getWrap(wrapper, pubkey, seal, tags) + + return Object.assign(rumor, {wrap}) as UnwrappedEvent +} + +export const unwrap = async (signer: ISigner, wrap: SignedEvent) => { + // Avoid decrypting the same event multiple times + if (seen.has(wrap.id)) { + const rumorOrError = seen.get(wrap.id) + + if (rumorOrError instanceof Error) { + throw rumorOrError + } else { + return rumorOrError + } + } + + try { + const seal = JSON.parse(await decrypt(signer, wrap.pubkey, wrap.content)) + const rumor = JSON.parse(await decrypt(signer, seal.pubkey, seal.content)) + + if (seal.pubkey !== rumor.pubkey) throw new Error("Seal pubkey does not match rumor pubkey") + + seen.set(wrap.id, rumor) + + return Object.assign(rumor, {wrap}) as UnwrappedEvent + } catch (error) { + seen.set(wrap.id, error as Error) + + throw error + } +} + +// This is a utility that makes it harder to re-use wrapper signers, since that can result in +// leaked metadata. It simultaneously makes it easier to wrap stuff, because it allows for +// wrapping a single user signer and omit the wrapper signer argument to wrap, while still +// making it possible to pass a wrapper signer if desired. +export class Nip59 { + constructor(private signer: ISigner, private wrapper?: ISigner) {} + + static fromSigner = (signer: ISigner) => new Nip59(signer) + + static fromSecret = (secret: string) => new Nip59(new Nip01Signer(secret)) + + withWrapper = (wrapper: ISigner) => new Nip59(this.signer, wrapper) + + wrap = (pubkey: string, template: EventTemplate, tags: string[][] = []) => + wrap(this.signer, this.wrapper || Nip01Signer.ephemeral(), pubkey, template, tags) + + unwrap = (event: SignedEvent) => + unwrap(this.signer, event) +} diff --git a/packages/signer/src/signers/secret.ts b/packages/signer/src/signers/nip01.ts similarity index 74% rename from packages/signer/src/signers/secret.ts rename to packages/signer/src/signers/nip01.ts index 4dfee52..d27026d 100644 --- a/packages/signer/src/signers/secret.ts +++ b/packages/signer/src/signers/nip01.ts @@ -1,14 +1,16 @@ import {EventTemplate} from '@welshman/util' -import {nip04, nip44, own, hash, sign, getPubkey, ISigner} from "../util" +import {nip04, nip44, own, hash, sign, getPubkey, ISigner, makeSecret} from "../util" -export class SecretSigner implements ISigner { +export class Nip01Signer implements ISigner { private pubkey: string constructor(private secret: string) { this.pubkey = getPubkey(this.secret) } - isEnabled = () => true + static fromSecret = (secret: string) => new Nip01Signer(secret) + + static ephemeral = () => new Nip01Signer(makeSecret()) getPubkey = async () => this.pubkey diff --git a/packages/signer/src/signers/extension.ts b/packages/signer/src/signers/nip07.ts similarity index 59% rename from packages/signer/src/signers/extension.ts rename to packages/signer/src/signers/nip07.ts index b9bc0a7..29236d2 100644 --- a/packages/signer/src/signers/extension.ts +++ b/packages/signer/src/signers/nip07.ts @@ -1,23 +1,23 @@ import {EventTemplate} from '@welshman/util' import {hash, own, Sign, ISigner, EncryptionImplementation} from '../util' -export type Extension = { - sign: Sign +export type Nip07 = { + signEvent: Sign nip04: EncryptionImplementation nip44: EncryptionImplementation getPublicKey: () => string | undefined } -export class ExtensionSigner implements ISigner { +export const getNip07 = () => (window as {nostr?: Nip07}).nostr + +export class Nip07Signer implements ISigner { #lock = Promise.resolve() - #ext = () => (window as {nostr?: Extension}).nostr - - #then = async (f: (ext: Extension) => T | Promise) => { + #then = async (f: (ext: Nip07) => T | Promise) => { const promise = this.#lock.then(() => { - const ext = this.#ext() + const ext = getNip07() - if (!ext) throw new Error("Extension is not enabled") + if (!ext) throw new Error("Nip07 is not enabled") return f(ext) }) @@ -28,19 +28,13 @@ export class ExtensionSigner implements ISigner { return promise } - isEnabled = () => Boolean(this.#ext()) + getPubkey = async () => getNip07()!.getPublicKey()! - getPubkey = () => - this.#then(ext => { - const pubkey = ext.getPublicKey() + sign = async (template: EventTemplate) => { + const event = hash(own(await this.getPubkey(), template)) - 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)))) + return this.#then(ext => ext.signEvent(event)) + } nip04 = { encrypt: (pubkey: string, message: string) => diff --git a/packages/signer/src/signers/connect.ts b/packages/signer/src/signers/nip46.ts similarity index 85% rename from packages/signer/src/signers/connect.ts rename to packages/signer/src/signers/nip46.ts index b438d5a..b39d05b 100644 --- a/packages/signer/src/signers/connect.ts +++ b/packages/signer/src/signers/nip46.ts @@ -4,41 +4,41 @@ 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' +import {Nip01Signer} from './nip01' -export type NostrConnectHandler = { - pubkey: string +export type Nip46Handler = { relays: string[] + pubkey?: string domain?: string } -export type ConnectResponse = { +export type Nip46Response = { id: string error?: string result?: string } -let singleton: NostrConnectBroker +let singleton: Nip46Broker // 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 { +export class Nip46Broker extends Emitter { #sub: Subscription #signer: ISigner #ready = sleep(500) #closed = false #connectResult: any - static get(pubkey: string, secret: string, handler: NostrConnectHandler) { + static get(pubkey: string, secret: string, handler: Nip46Handler) { if ( singleton?.pubkey !== pubkey || singleton?.secret !== secret || !equals(singleton?.handler, handler) ) { singleton?.teardown() - singleton = new NostrConnectBroker(pubkey, secret, handler) + singleton = new Nip46Broker(pubkey, secret, handler) } return singleton @@ -47,11 +47,11 @@ export class NostrConnectBroker extends Emitter { constructor( readonly pubkey: string, readonly secret: string, - readonly handler: NostrConnectHandler, + readonly handler: Nip46Handler, ) { super() - this.#signer = new SecretSigner(secret) + this.#signer = new Nip01Signer(secret) this.#sub = this.subscribe() } @@ -96,7 +96,7 @@ export class NostrConnectBroker extends Emitter { await this.#ready const id = randomId() - const pubkey = admin ? this.handler.pubkey : this.pubkey + 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]]}) @@ -109,7 +109,7 @@ export class NostrConnectBroker extends Emitter { }) return new Promise((resolve, reject) => { - this.once(`res-${id}`, ({result, error}: ConnectResponse) => { + this.once(`res-${id}`, ({result, error}: Nip46Response) => { if (error) { reject(error as string) } else { @@ -163,10 +163,8 @@ export class NostrConnectBroker extends Emitter { } } -export class ConnectSigner implements ISigner { - constructor(private broker: NostrConnectBroker) {} - - isEnabled = () => true +export class Nip46Signer implements ISigner { + constructor(private broker: Nip46Broker) {} getPubkey = async () => this.broker.pubkey diff --git a/packages/signer/src/util.ts b/packages/signer/src/util.ts index 7e1e472..2fd2fb1 100644 --- a/packages/signer/src/util.ts +++ b/packages/signer/src/util.ts @@ -4,7 +4,7 @@ import {nip04 as nt04, nip44 as nt44, generateSecretKey, getPublicKey, getEventH import {cached} from '@welshman/lib' import {SignedEvent, HashedEvent, EventTemplate, OwnedEvent} from '@welshman/util' -export const getSecret = () => bytesToHex(generateSecretKey()) +export const makeSecret = () => bytesToHex(generateSecretKey()) export const getPubkey = (secret: string) => getPublicKey(hexToBytes(secret)) @@ -47,11 +47,10 @@ export type EncryptionImplementation = { } export interface ISigner { - isEnabled: () => boolean - getPubkey: () => Promise sign: Sign nip04: EncryptionImplementation nip44: EncryptionImplementation + getPubkey: () => Promise } export const decrypt = async (signer: ISigner, pubkey: string, message: string) => diff --git a/packages/signer/src/wrapper.ts b/packages/signer/src/wrapper.ts deleted file mode 100644 index c105aa6..0000000 --- a/packages/signer/src/wrapper.ts +++ /dev/null @@ -1,77 +0,0 @@ -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 index dd7b078..6c37019 100644 --- a/packages/signer/tsc-multi.json +++ b/packages/signer/tsc-multi.json @@ -1,5 +1,6 @@ { "targets": [ + {"extname": ".cjs", "module": "commonjs"}, {"extname": ".mjs", "module": "esnext", "moduleResolution": "node"} ], "projects": ["tsconfig.json"]