diff --git a/docs/app/session.md b/docs/app/session.md index db5aa27..6b481cb 100644 --- a/docs/app/session.md +++ b/docs/app/session.md @@ -22,7 +22,7 @@ Sessions are stored in local storage and can be: The simplest type of login is NIP 01, although it's generally a bad idea to be handling user keys. NIP 46, 44, or 07 login are preferable. However, NIP 01 can be useful for supporting signup, local profiles, or ephemeral keys. ```typescript -import {makeSecret} from '@welshman/signer' +import {makeSecret} from '@welshman/util' import {loginWithNip01} from '@welshman/app' loginWithNip01(makeSecret()) @@ -54,7 +54,8 @@ The best default signing scheme is [NIP 46](https://github.com/nostr-protocol/ni The simpler `bunker://` handshake is done by asking the user to provide a bunker URL, either by QR code, or by pasting it manually into your application. ```typescript -import {Nip46Broker, makeSecret} from "@welshman/signer" +import {makeSecret} from "@welshman/util" +import {Nip46Broker} from "@welshman/signer" import {loginWithNip46, nip46Perms} from "@welshman/app" import {isKeyValid} from "src/util/nostr" @@ -96,7 +97,8 @@ if (!isKeyValid(signerPubkey)) { Alternatively, you can provide the user with a `nostrconnect://` URL which they can copy or scan with their signer. This is a better UX for users using a signer on their mobile phone. ```typescript -import {Nip46Broker, makeSecret} from "@welshman/signer" +import {makeSecret} from "@welshman/util" +import {Nip46Broker} from "@welshman/signer" import {loginWithNip46, nip46Perms} from "@welshman/app" // Create a client secret diff --git a/docs/signer/index.md b/docs/signer/index.md index 4a6192c..1452611 100644 --- a/docs/signer/index.md +++ b/docs/signer/index.md @@ -16,8 +16,8 @@ A Nostr signer implementation that supports multiple authentication methods and ## Quick Example ```typescript -import { makeEvent } from '@welshman/util' -import { ISigner, Nip01Signer, makeSecret } from '@welshman/signer' +import { makeEvent, makeSecret } from '@welshman/util' +import { ISigner, Nip01Signer } from '@welshman/signer' const signer: ISigner = new Nip01Signer(makeSecret()) const options = { diff --git a/packages/signer/package.json b/packages/signer/package.json index 3900028..bd115eb 100644 --- a/packages/signer/package.json +++ b/packages/signer/package.json @@ -20,12 +20,13 @@ "prepublishOnly": "pnpm run build" }, "dependencies": { - "@noble/curves": "^1.7.0", - "@noble/hashes": "^1.6.1", + "@jsr/fiatjaf__promenade-trusted-dealer": "^0.4.1", + "@noble/curves": "^1.9.7", + "@noble/hashes": "^2.0.1", "@welshman/lib": "workspace:*", "@welshman/net": "workspace:*", "@welshman/util": "workspace:*", - "nostr-tools": "^2.14.2" + "nostr-tools": "^2.18.2" }, "devDependencies": { "@capacitor/core": "^7.2.0", diff --git a/packages/signer/src/signers/nip46.ts b/packages/signer/src/signers/nip46.ts index 747205b..5aa221b 100644 --- a/packages/signer/src/signers/nip46.ts +++ b/packages/signer/src/signers/nip46.ts @@ -1,14 +1,36 @@ -import {Emitter, throttle, makePromise, defer, sleep, tryCatch, randomId} from "@welshman/lib" +import {trustedKeyDeal, hexShard, hexPubShard, KeyShard} from "@jsr/fiatjaf__promenade-trusted-dealer" +import {bytesToHex, hexToBytes, numberToBytesBE} from "@noble/curves/abstract/utils" +import type {AffinePoint} from "@noble/curves/abstract/curve" import { + Emitter, + uniq, + spec, + inc, + throttle, + makePromise, + defer, + sleep, + tryCatch, + randomId, + MaybeAsync, + shuffle, +} from "@welshman/lib" +import { + getPubkey, + HashedEvent, makeEvent, + makeSecret, normalizeRelayUrl, - TrustedEvent, - StampedEvent, NOSTR_CONNECT, - hash, - own, + prep, + PROMENADE_REGISTER_ACCOUNT, + PROMENADE_SHARD_ACK, + PROMENADE_SHARD_SHARE, + RelayMode, + StampedEvent, + TrustedEvent, } from "@welshman/util" -import {publish, request, AdapterContext} from "@welshman/net" +import {publish, request, PublishStatus, AdapterContext} from "@welshman/net" import {ISigner, EncryptionImplementation, signWithOptions, SignOptions, decrypt} from "../util.js" import {Nip01Signer} from "./nip01.js" @@ -20,6 +42,12 @@ export const nip46Context = { debug: false, } +const nip46Log = (...args: any[]) => { + if (nip46Context.debug) { + console.log(...args) + } +} + export type Nip46Algorithm = "nip04" | "nip44" export enum Nip46Event { @@ -58,6 +86,68 @@ export type Nip46ResponseWithError = { error: string } +export type PromenadeOptions = { + secret: string + policy: [number, number] + coordinatorUrl: string + signerPubkeys: string[] + onProgress?: (progress: number) => void + generatePow: (event: HashedEvent, difficulty: number) => MaybeAsync + getPubkeyRelays: (pubkey: string, mode: RelayMode) => MaybeAsync +} + +/* + +const secret = 'fd8a80772a55d82ed963305b0f55299ac07e2fdf06d341f9e7c7223ec7bf57b0' +const pubkey = getPubkey(secret) +const coordinatorUrl = 'wss://promenade.coracle.social/' +const event = prep( + makeEvent(10002, { + tags: [["r", "wss://bucket.coracle.social/"], ["r", "wss://relay.damus.io/"], ["r", "wss://nos.lol/"]], + }), + pubkey, +) +event.sig = getSig(event, secret) +repository.publish(event) +nip46Context.debug = true +publish({event, relays: ["wss://purplepag.es/", "wss://indexer.coracle.social/"]}).then(async () => { + console.log("Published outbox relays") + const broker = await Nip46Broker.fromPromenade({ + secret, + policy: [2, 3], + coordinatorUrl, + signerPubkeys: [ + // '4440e4f93c9dcb0a5521f0bf949a1222698b72a1b1e3534b10537100fc94f97f', + // '23a3ff76766f5ffc852fa6f2fc5058c1306ee25927632e0f8e213af11a5b8de5', + 'aa4f53d8041b88adee44cefb62fb49fdeb85d151d1a346e655850c213508ed2e', + // 'ad1c6fa1daca939685d34ab541fc9e7b450ef6295aa273addafee74a579d57fb', + // '3fcd012e970d9dfba4bc638ae9b6420e2ceca76f3b8e31d0ee3f408023a7c5fd', + // '4be49a6175734b43c7083ceac11e47bf684ffe65bd021c949bea1702409c119a', + '290238f7811a50b2b3ded97e42695f906b039fb3f5e2e2e3f77fd5a0b0c9a027', + 'c66bebe38406a0b57593fcd8c893762dd9af8e488664c6d1a4eb3868b1f65526', + ], + onProgress: p => console.log('progress', p), + generatePow: (e, d) => makePow(e, d).result, + getPubkeyRelays: async (k, m) => { + await forceLoadRelayList(k) + return getPubkeyRelays(k, m) + }, + }) + console.log('connect', await broker.connect(broker.params.connectSecret)) + console.log('sign', await broker.signEvent(makeEvent(1))) +}) + +*/ + +export class PromenadeShardError extends Error { + constructor( + message: string, + readonly errorsBySignerPubkey: Map, + ) { + super(message) + } +} + const popupManager = (() => { let pendingUrl = "" let pendingSince = 0 @@ -188,9 +278,7 @@ export class Nip46Sender extends Emitter { try { await this.send(request) } catch (error: any) { - if (nip46Context.debug) { - console.log("nip46 error:", error, request) - } + nip46Log("nip46 error:", error, request) } } } finally { @@ -286,6 +374,154 @@ export class Nip46Broker extends Emitter { return {relays, signerPubkey, connectSecret} } + static fromBunkerUrl = (url: string) => { + const clientSecret = makeSecret() + const {relays, signerPubkey, connectSecret} = Nip46Broker.parseBunkerUrl(url) + + return new Nip46Broker({ + relays, + clientSecret, + signerPubkey, + connectSecret, + }) + } + + static fromPromenade = async (options: PromenadeOptions) => { + const [m, n] = options.policy + + if (options.signerPubkeys.length < n) { + throw new Error("Not enough signers to create all shards") + } + + const deal = trustedKeyDeal(BigInt("0x" + options.secret), m, n) + + // Add the VSS commits to each shard + // for (const shard of deal.shards) { + // shard.pubShard.vssCommit = deal.commits + // } + + // Use the pubkey and adjusted secret from the deal (BIP-340 adjusted if needed) + const signer = Nip01Signer.fromSecret(options.secret) + const ourPubkey = await signer.getPubkey() + const ackRelays = await options.getPubkeyRelays(ourPubkey, RelayMode.Read) + const remainingSignerPubkeys = shuffle(uniq(options.signerPubkeys)) + const errorsBySignerPubkey = new Map() + const shardsBySignerPubkey = new Map() + + if (ackRelays.length === 0) { + throw new Error("No read relays returned for user pubkey") + } + + nip46Log(`generated promenade shards for user ${ourPubkey}`, deal) + + await Promise.all( + deal.shards.map(async (shard, i) => { + while (remainingSignerPubkeys.length > 0) { + const signerPubkey = remainingSignerPubkeys.shift()! + + nip46Log(`generating proof of work for shard ${i}`) + + const shardTemplate = makeEvent(PROMENADE_SHARD_SHARE, { + content: await signer.nip44.encrypt(signerPubkey, hexShard(shard)), + tags: [ + ["p", signerPubkey], + ["coordinator", options.coordinatorUrl], + ...ackRelays.map(url => ["reply", url]), + ], + }) + + const shardTemplateWithWork = await tryCatch(() => + options.generatePow(prep(shardTemplate, ourPubkey), 20), + ) + + if (!shardTemplateWithWork) { + errorsBySignerPubkey.set(signerPubkey, "Failed to generate work") + continue + } + + const shardEvent = await signer.sign(shardTemplateWithWork) + const shardRelays = await options.getPubkeyRelays(signerPubkey, RelayMode.Read) + const publishResults = await publish({relays: shardRelays, event: shardEvent}) + + nip46Log(`published shard ${i} to signer ${signerPubkey}`, shardRelays, publishResults) + + if (!Object.values(publishResults).some(spec({status: PublishStatus.Success}))) { + errorsBySignerPubkey.set(signerPubkey, "Failed to publish shard") + continue + } + + const controller = new AbortController() + const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) + + await request({ + signal, + relays: ackRelays, + filters: [ + { + kinds: [PROMENADE_SHARD_ACK], + authors: [signerPubkey], + "#p": [ourPubkey], + "#e": [shardEvent.id], + }, + ], + onEvent: (event: TrustedEvent, url: string) => { + nip46Log(`received ack for shard ${i} from signer ${signerPubkey} on ${url}`) + shardsBySignerPubkey.set(signerPubkey, shard) + options.onProgress?.(shardsBySignerPubkey.size / inc(n)) + controller.abort() + }, + }) + + if (shardsBySignerPubkey.has(signerPubkey)) { + break + } else { + errorsBySignerPubkey.set(signerPubkey, "Failed to receive shard ACK") + nip46Log(`failed to receive ack for shard ${i} from signer ${signerPubkey}`) + } + } + }), + ) + + if (shardsBySignerPubkey.size < deal.shards.length) { + throw new PromenadeShardError("Failed to publish all shards", errorsBySignerPubkey) + } + + const connectSecret = randomId() + const signerSecret = makeSecret() + const signerPubkey = getPubkey(signerSecret) + const tags = [ + ["h", signerPubkey], + ["threshold", String(m)], + ["handlersecret", signerSecret], + ["profile", "MAIN", connectSecret, ""], + ] + + for (const [pubkey, shard] of shardsBySignerPubkey) { + tags.push(["p", pubkey, hexPubShard(shard.pubShard)]) + } + + nip46Log(`registering coordinator account`, tags) + + const relays = [options.coordinatorUrl] + const event = await signer.sign(makeEvent(PROMENADE_REGISTER_ACCOUNT, {tags})) + const accountResults = await publish({relays, event}) + + if (!Object.values(accountResults).some(spec({status: PublishStatus.Success}))) { + throw new Error("Failed to publish accounts to coordinator") + } + + nip46Log(`successfully created promenade broker`) + + const clientSecret = makeSecret() + + return new Nip46Broker({ + relays, + clientSecret, + signerPubkey, + connectSecret, + }) + } + // Getters for helper objects makeSigner = () => new Nip01Signer(this.params.clientSecret) @@ -294,9 +530,7 @@ export class Nip46Broker extends Emitter { const sender = new Nip46Sender(this.signer, this.params) sender.on(Nip46Event.Send, (data: any) => { - if (nip46Context.debug) { - console.log("nip46 send:", data) - } + nip46Log("nip46 send:", data) }) return sender @@ -306,9 +540,7 @@ export class Nip46Broker extends Emitter { const receiver = new Nip46Receiver(this.signer, this.params) receiver.on(Nip46Event.Receive, (data: any) => { - if (nip46Context.debug) { - console.log("nip46 receive:", data) - } + nip46Log("nip46 receive:", data) }) return receiver @@ -467,7 +699,7 @@ export class Nip46Signer implements ISigner { sign = (template: StampedEvent, options: SignOptions = {}) => signWithOptions( - this.getPubkey().then(pubkey => this.broker.signEvent(hash(own(template, pubkey)))), + this.getPubkey().then(pubkey => this.broker.signEvent(prep(template, pubkey))), options, ) } diff --git a/packages/signer/src/util.ts b/packages/signer/src/util.ts index 735e679..17d1d0b 100644 --- a/packages/signer/src/util.ts +++ b/packages/signer/src/util.ts @@ -1,4 +1,4 @@ -import {hexToBytes} from "@noble/hashes/utils" +import {hexToBytes} from "@noble/curves/abstract/utils" import * as nt04 from "nostr-tools/nip04" import * as nt44 from "nostr-tools/nip44" import {Emitter, cached} from "@welshman/lib" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad02825..fb2e2d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -383,12 +383,15 @@ importers: packages/signer: dependencies: + '@jsr/fiatjaf__promenade-trusted-dealer': + specifier: ^0.4.1 + version: 0.4.1 '@noble/curves': - specifier: ^1.7.0 - version: 1.8.1 + specifier: ^1.9.7 + version: 1.9.7 '@noble/hashes': - specifier: ^1.6.1 - version: 1.7.1 + specifier: ^2.0.1 + version: 2.0.1 '@welshman/lib': specifier: workspace:* version: link:../lib @@ -399,8 +402,8 @@ importers: specifier: workspace:* version: link:../util nostr-tools: - specifier: ^2.14.2 - version: 2.14.2(typescript@5.8.2) + specifier: ^2.18.2 + version: 2.18.2(typescript@5.8.2) devDependencies: '@capacitor/core': specifier: ^7.2.0 @@ -849,6 +852,15 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@jsr/fiatjaf__promenade-trusted-dealer@0.4.1': + resolution: {integrity: sha512-K9WjpDkQGyLl5gUZBLr3Gb+b5b1r8miZmDOo4+ZlzGQgoXD2TaqT+dkBjL/yLj/pYwBcd1Bschv0xuNpguL2ZQ==, tarball: https://npm.jsr.io/~/11/@jsr/fiatjaf__promenade-trusted-dealer/0.4.1.tgz} + + '@jsr/henrygd__semaphore@0.0.2': + resolution: {integrity: sha512-nrwZ8HaqU1Agb2ij8omIxaOCAsKkDHWcwS9hTRumPhZuptwh6/0BJExBd8+eClTYM7jBnZxK+cP4WJRTcHBvCA==, tarball: https://npm.jsr.io/~/11/@jsr/henrygd__semaphore/0.0.2.tgz} + + '@jsr/nostr__tools@2.16.2': + resolution: {integrity: sha512-QK1XwHvAnqEwbimD+ywbLQ3T2iI+/qE/zrRgOhmtjoEGlCWgtbPTNJ6Y/MEunXr6H/MnuHV+s400i/Yk4suvGQ==, tarball: https://npm.jsr.io/~/11/@jsr/nostr__tools/2.16.2.tgz} + '@microsoft/api-extractor-model@7.32.1': resolution: {integrity: sha512-u4yJytMYiUAnhcNQcZDTh/tVtlrzKlyKrQnLOV+4Qr/5gV+cpufWzCYAB1Q23URFqD6z2RoL2UYncM9xJVGNKA==} @@ -875,6 +887,10 @@ packages: resolution: {integrity: sha512-warwspo+UYUPep0Q+vtdVB4Ugn8GGQj8iyB3gnRWsztmUHTI3S1nhdiWNsPUGL0vud7JlRRk1XEu7Lq1KGTnMQ==} engines: {node: ^14.21.3 || >=16} + '@noble/curves@1.9.7': + resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} + engines: {node: ^14.21.3 || >=16} + '@noble/hashes@1.3.1': resolution: {integrity: sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==} engines: {node: '>= 16'} @@ -887,6 +903,14 @@ packages: resolution: {integrity: sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==} engines: {node: ^14.21.3 || >=16} + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@2.0.1': + resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2331,6 +2355,14 @@ packages: typescript: optional: true + nostr-tools@2.18.2: + resolution: {integrity: sha512-lUCJQd9YZG3kEvxV5Zgm7qUkBpaeuvFrtqBz4TJLAxHzUn2pE7nmZZRDQmNzp5neEw20tQS3jR16o7XzzF8ncg==} + peerDependencies: + typescript: '>=5.0.0' + peerDependenciesMeta: + typescript: + optional: true + nostr-wasm@0.1.0: resolution: {integrity: sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==} @@ -3407,6 +3439,24 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@jsr/fiatjaf__promenade-trusted-dealer@0.4.1': + dependencies: + '@jsr/henrygd__semaphore': 0.0.2 + '@jsr/nostr__tools': 2.16.2 + '@noble/curves': 1.9.7 + + '@jsr/henrygd__semaphore@0.0.2': {} + + '@jsr/nostr__tools@2.16.2': + dependencies: + '@noble/ciphers': 0.5.3 + '@noble/curves': 1.2.0 + '@noble/hashes': 1.3.1 + '@scure/base': 1.1.1 + '@scure/bip32': 1.3.1 + '@scure/bip39': 1.2.1 + nostr-wasm: 0.1.0 + '@microsoft/api-extractor-model@7.32.1(@types/node@22.13.17)': dependencies: '@microsoft/tsdoc': 0.16.0 @@ -3457,12 +3507,20 @@ snapshots: dependencies: '@noble/hashes': 1.7.1 + '@noble/curves@1.9.7': + dependencies: + '@noble/hashes': 1.8.0 + '@noble/hashes@1.3.1': {} '@noble/hashes@1.3.2': {} '@noble/hashes@1.7.1': {} + '@noble/hashes@1.8.0': {} + + '@noble/hashes@2.0.1': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -5047,6 +5105,18 @@ snapshots: optionalDependencies: typescript: 5.8.2 + nostr-tools@2.18.2(typescript@5.8.2): + dependencies: + '@noble/ciphers': 0.5.3 + '@noble/curves': 1.2.0 + '@noble/hashes': 1.3.1 + '@scure/base': 1.1.1 + '@scure/bip32': 1.3.1 + '@scure/bip39': 1.2.1 + nostr-wasm: 0.1.0 + optionalDependencies: + typescript: 5.8.2 + nostr-wasm@0.1.0: {} onchange@7.1.0: