diff --git a/docs/app/index.md b/docs/app/index.md index e96ab5f..d860525 100644 --- a/docs/app/index.md +++ b/docs/app/index.md @@ -6,13 +6,13 @@ A comprehensive framework for building nostr clients, powering production applic ## What's Included -- **Repository System** - Event storage and query capabilities -- **Router** - Intelligent relay selection for optimal networking -- **Feed Controller** - Manages feed creation and updates +- **Repository** - Event storage and query capabilities +- **Router** - Intelligent relay selection for optimal network access +- **Feed Controller** - Manages feed loading - **Session Management** - User identity and key management - **Event Actions** - High-level operations like reacting, replying, etc. - **Profile Management** - User profile handling and metadata -- **Relay Directories** - Discovery and management of relays +- **Relay Directory** - Discovery and management of relays - **Web of Trust** - Utilities for building webs of trust ## Quick Example @@ -61,18 +61,19 @@ const events = await load({ }]) // Or use `request` for more fine-grained subscription control -const req = request({ +const abortController = new AbortController() + +request({ + signal: abortController.signal, relays: Router.get().ForUser().getUrls(), filters: [{kinds: [NOTE], + onEvent: (event: TrustedEvent) => { + console.log(event) + }, }]) -// Listen for events -req.on(RequestEvent.Event, (event: TrustedEvent) => { - console.log(event) -}) - -// Close the req -req.close() +// Close the request +abortController.abort() ``` ## Installation diff --git a/docs/app/session.md b/docs/app/session.md index 3483d24..db5aa27 100644 --- a/docs/app/session.md +++ b/docs/app/session.md @@ -6,6 +6,7 @@ The session system provides a unified way to handle different authentication met - NIP-07 via Browser Extension - NIP-46 via Bunker URL or Nostrconnect - NIP-55 via Android Signer Application +- Read-only pubkey login ## Overview @@ -16,96 +17,192 @@ Sessions are stored in local storage and can be: - Switched dynamically - Backed by different signing methods -## Basic Usage +## NIP 01 Example + +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 {sessions, pubkey, addSession, dropSession} from '@welshman/app' +import {makeSecret} from '@welshman/signer' +import {loginWithNip01} from '@welshman/app' -// Add multiple sessions -addSession({method: 'nip07', pubkey: 'abc...'}) -addSession({method: 'nip46', pubkey: 'def...', secret: '123'}) - -// Switch between sessions -pubkey.set('abc...') // Activates that session - -// Remove a session -dropSession('abc...') - -// List all sessions -console.log(sessions.get()) +loginWithNip01(makeSecret()) ``` -## NIP-46 (Bunker) Authentication +## NIP 07 Example + +A simple way to sign in for desktop browser users is using [NIP 07](https://github.com/nostr-protocol/nips/blob/master/07.md). This method is easy to implement, but should be used sparingly, since not all users will be using a browser with a nostr signing extension installed. ```typescript -import {Nip46Broker, Nip46Signer} from '@welshman/signer' -import {addSession} from '@welshman/app' +import {Nip07Signer} from '@welshman/signer' +import {loginWithNip07} from '@welshman/app' -// Connect to a bunker -const clientSecret = makeSecret() -const relays = ['wss://relay.damus.io'] -const broker = Nip46Broker.get({relays, clientSecret}) +const signer = new Nip07Signer() -// Generate nostrconnect URL for the bunker -const connectUrl = await broker.makeNostrconnectUrl({ - name: "My App", - url: "https://myapp.com" -}) - -// Wait for user to approve in bunker -const response = await broker.waitForNostrconnect(connectUrl) - -// Create session -addSession({ - method: 'nip46', - pubkey: response.event.pubkey, - secret: clientSecret, - handler: { - pubkey: response.event.pubkey, - relays +signer.getPubkey().then(pubkey => { + if (pubkey) { + loginWithNip07(pubkey) + } else { + // User extension does not exist or did not respond } }) ``` -## Using Session Signer +## NIP-46 Authentication + +The best default signing scheme is [NIP 46](https://github.com/nostr-protocol/nips/blob/master/46.md), AKA "Nostr Connect". This supports multiple handshakes depending on desired UX, and can support advanced use cases like secure enclaves, self-hosted keys, and FROST multisig. + +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 {loginWithNip46, nip46Perms} from "@welshman/app" +import {isKeyValid} from "src/util/nostr" + +// Make a client secret - this is distinct from the user's private key, and is used +// for communicating securely with the remote signer +const clientSecret = makeSecret() + +// Ask the user to input their bunker URL +const bunkerUrl = prompt("Please enter your bunker url") + +// Pase the bunker url +const {signerPubkey, connectSecret, relays} = Nip46Broker.parseBunkerUrl(bunkerUrl) + +if (!isKeyValid(signerPubkey)) { + alert("Sorry, but that's an invalid public key.") +} else if (relays.length === 0) { + alert("That connection string doesn't have any relays.") +} else { + // Open up a connection with the signer + const broker = Nip46Broker.get({relays, clientSecret, signerPubkey}) + + // Send a connect request with the default permissions + const result = await broker.connect(connectSecret, nip46Perms) + + // Make sure to check the connect secret to prevent hijacking + if (result === connectSecret) { + // Get the user's public key + const pubkey = await broker.getPublicKey() + + if (!pubkey) { + alert("Failed to initialize session") + } else { + loginWithNip46(pubkey, clientSecret, signerPubkey, relays) + } + } +} +``` + +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 {loginWithNip46, nip46Perms} from "@welshman/app" + +// Create a client secret +const clientSecret = makeSecret() + +// Stop listening if the user cancels login +const abortController = new AbortController() + +// Customize to use relays the signer can send responses to +const relays = ['wss://relay.nsec.app/'] + +// Create a broker +const broker = Nip46Broker.get({clientSecret, relays}) + +// Create a nostrconnect:// url +const nostrconnect = await broker.makeNostrconnectUrl({ + name: "My App", + url: window.origin, + image: window.origin + '/logo.png', + perms: nip46Perms, +}) + +// Share it with the user. Displaying a QR code is particularly helpful +alert("To connect, paste this URL into your signer: " + nostrconnect) + +// Listen for the response +let response +try { + response = await broker.waitForNostrconnect(nostrconnect, abortController.signal) +} catch (errorResponse: any) { + if (errorResponse?.error) { + alert(`Received error from signer: ${errorResponse.error}`) + } else if (errorResponse) { + console.error(errorResponse) + } +} + +// If we got a response, the broker is already connected and we can log in +if (response) { + const pubkey = await broker.getPublicKey() + + if (!pubkey) { + alert("Failed to initialize session") + } else { + loginWithNip46(pubkey, clientSecret, response.event.pubkey, relays) + } +} +``` + +## NIP-55 Authentication + +For the best UX on Android, use [NIP 55](https://github.com/nostr-protocol/nips/blob/master/55.md). Note that this only works for web applications that have been compiled to native Android applications using [CapacitorJS](https://capacitorjs.com/) and [nostr-signer-capacitor-plugin](https://github.com/chebizarro/nostr-signer-capacitor-plugin). + +```typescript +import {getNip55, Nip55Signer, loginWithNip55} from "@welshman/signer" + +// Query for installed apps that implement nip 55 signing +getNip55().then(signerApps => { + // We'll choose the first one and auto-login, but in most cases you'll want to offer a choice + if (signerApps.length > 0) { + const signer = new Nip55Signer(signerApps[0].packageName) + const pubkey = await signer.getPubkey() + + if (pubkey) { + loginWithNip55(pubkey, app.packageName) + } + } +}) +``` + +## Read-only session + +A fun feature of nostr is that you can log in as other people, and see what nostr is like from their perspective (minus encrypted data or course). + +```typescript +import {loginWithPubkey} from "@welshman/signer" + +// Log in as hodlbod +loginWithPubkey("97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322") +``` + +## Using the current session ```typescript import {signer, session} from '@welshman/app' import {createEvent, NOTE} from '@welshman/util' +// Print the current session - be aware the private key is stored in memory, be very +// careful about how you handle session objects! +console.log(session.get()) + // Current session's signer is always ready to use const event = await signer.get().sign( createEvent(NOTE, {content: "Hello Nostr!"}) ) +// hodlbod's pubkey +const otherPubkey = "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322" + // Encrypt content for private notes -const encrypted = await signer.get().nip44.encrypt( - pubkey, - "Secret message" -) +const ciphertext = await signer.get().nip44.encrypt(otherPubkey, "Secret message") + +// Decrypt automatically detects encryption version +const plaintext = await decrypt(signer, otherPubkey, ciphertext) ``` -## Session Types +## Multiple sessions -```typescript -type SessionNip07 = { - method: "nip07" - pubkey: string -} - -type SessionNip46 = { - method: "nip46" - pubkey: string - secret: string - handler: { - pubkey: string - relays: string[] - } -} - -type SessionNip01 = { - method: "nip01" - pubkey: string - secret: string -} -``` +It's possible to support multiple concurrent sessions by simply calling `addSession` multiple times. This will update `sessions`, and set `pubkey` to the most recently added session. You can then switch between sessions by calling `pubkey.set` with a valid session pubkey, and delete sessions using `dropSession(pubkey)`. diff --git a/docs/what-is-welshman.md b/docs/what-is-welshman.md index f88a86e..706901a 100644 --- a/docs/what-is-welshman.md +++ b/docs/what-is-welshman.md @@ -5,10 +5,9 @@ outline: deep # What is Welshman? Welshman is a production-grade nostr toolkit powering [Coracle](https://coracle.social) and [Flotilla](https://flotilla.social). -Built as independent, opt-in packages, it lets you choose exactly what you need - from basic event handling to complete feed management. + +Built as independent, opt-in packages, it lets you choose exactly what you need - from basic utilities handling to a batteries-included application framework. Need just a content parser? Grab @welshman/content. Building a complex client? Start with @welshman/app and add more packages as you grow. -Each module is battle-tested in production, designed to work together but never dependent on each other. - -Build your next Nostr client with the same tools that power today's leading Nostr applications. +Each module is battle-tested in production, and designed to work together. diff --git a/packages/app/src/session.ts b/packages/app/src/session.ts index 9fecc02..731b972 100644 --- a/packages/app/src/session.ts +++ b/packages/app/src/session.ts @@ -1,21 +1,29 @@ import {derived} from "svelte/store" import {cached, hash, omit, equals, assoc} from "@welshman/lib" import {withGetter, synced} from "@welshman/store" -import {Nip46Broker, Nip46Signer, Nip07Signer, Nip01Signer, Nip55Signer} from "@welshman/signer" +import {Nip46Broker, Nip46Signer, Nip07Signer, Nip01Signer, Nip55Signer, getPubkey} from "@welshman/signer" + +export enum SessionMethod { + Nip01 = 'nip01', + Nip07 = 'nip07', + Nip46 = 'nip46', + Nip55 = 'nip55', + Pubkey = 'pubkey', +} export type SessionNip01 = { - method: "nip01" + method: SessionMethod.Nip01 pubkey: string secret: string } export type SessionNip07 = { - method: "nip07" + method: SessionMethod.Nip07 pubkey: string } export type SessionNip46 = { - method: "nip46" + method: SessionMethod.Nip46 pubkey: string secret: string handler: { @@ -25,13 +33,13 @@ export type SessionNip46 = { } export type SessionNip55 = { - method: "nip55" + method: SessionMethod.Nip55 pubkey: string signer: string } export type SessionPubkey = { - method: "pubkey" + method: SessionMethod.Pubkey pubkey: string } @@ -71,6 +79,42 @@ export const updateSession = (pubkey: string, f: (session: Session) => Session) export const dropSession = (pubkey: string) => sessions.update($sessions => omit([pubkey], $sessions)) +// Session factories + +export const makeNip01Session = (secret: string): SessionNip01 => + ({method: SessionMethod.Nip01, secret, pubkey: getPubkey(secret)}) + +export const makeNip07Session = (pubkey: string): SessionNip07 => + ({method: SessionMethod.Nip07, pubkey}) + +export const makeNip46Session = (pubkey: string, clientSecret: string, signerPubkey: string, relays: string[]): SessionNip46 => + ({method: SessionMethod.Nip46, pubkey, secret: clientSecret, handler: {pubkey: signerPubkey, relays}}) + +export const makeNip55Session = (pubkey: string, signer: string): SessionNip55 => + ({method: SessionMethod.Nip55, pubkey, signer}) + +export const makePubkeySession = (pubkey: string): SessionPubkey => + ({method: SessionMethod.Pubkey, pubkey}) + +// Login utilities + +export const loginWithNip01 = (secret: string) => + addSession(makeNip01Session(secret)) + +export const loginWithNip07 = (pubkey: string) => + addSession(makeNip07Session(pubkey)) + +export const loginWithNip46 = (pubkey: string, clientSecret: string, signerPubkey: string, relays: string[]) => + addSession(makeNip46Session(pubkey, clientSecret, signerPubkey, relays)) + +export const loginWithNip55 = (pubkey: string, signer: string) => + addSession(makeNip55Session(pubkey, signer)) + +export const loginWithPubkey = (pubkey: string) => + addSession(makePubkeySession(pubkey)) + +// Other stuff + export const nip46Perms = "sign_event:22242,nip04_encrypt,nip04_decrypt,nip44_encrypt,nip44_decrypt" export const getSigner = cached({ diff --git a/packages/signer/src/signers/nip46.ts b/packages/signer/src/signers/nip46.ts index ff4c185..69e0c0d 100644 --- a/packages/signer/src/signers/nip46.ts +++ b/packages/signer/src/signers/nip46.ts @@ -372,7 +372,7 @@ export class Nip46Broker extends Emitter { return `nostrconnect://${clientPubkey}?${params.toString()}` } - waitForNostrconnect = (url: string, abort?: AbortController) => { + waitForNostrconnect = (url: string, signal: AbortSignal) => { const secret = new URL(url).searchParams.get("secret") return makePromise((resolve, reject) => { @@ -401,7 +401,7 @@ export class Nip46Broker extends Emitter { this.receiver.on(Nip46Event.Receive, onReceive) this.receiver.start() - abort?.signal.addEventListener("abort", () => { + signal.addEventListener("abort", () => { reject(undefined) cleanup() })