├── vitest.config.ts ├── .gitignore ├── lerna.json ├── tsconfig.build.json ├── tsconfig.json ├── biome.json ├── src ├── index.ts ├── helpers.ts ├── nip46 │ ├── signer.test.ts │ ├── relay_pool.ts │ ├── rpc.ts │ └── signer.ts ├── interface.ts ├── secret_key.ts └── nip07.ts ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── combine-prs.yml ├── .eslintrc.cjs ├── LICENSE ├── package.json └── README.md /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: {}, 5 | }); 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | docs/ 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | 12 | .vscode 13 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/@lerna-lite/cli/schemas/lerna-schema.json", 3 | "version": "0.6.0", 4 | "npmClient": "yarn", 5 | "packages": [ 6 | "./" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false, 5 | "declaration": true, 6 | "declarationDir": "./dist", 7 | "declarationMap": true, 8 | "emitDeclarationOnly": true, 9 | }, 10 | "exclude": ["src/**/*.spec.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://github.com/tsconfig/bases/blob/main/bases/strictest.json 3 | "extends": "@tsconfig/strictest/tsconfig.json", 4 | "compilerOptions": { 5 | "target": "ESNext", 6 | "lib": ["ESNext", "DOM"], 7 | "module": "ESNext", 8 | "moduleResolution": "Bundler", 9 | "noEmit": true, 10 | }, 11 | "include": ["src/**/*.ts"], 12 | } 13 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "formatter": { 7 | "enabled": true, 8 | "indentStyle": "space", 9 | "lineWidth": 120, 10 | "ignore": ["package.json"] 11 | }, 12 | "linter": { 13 | "enabled": true, 14 | "rules": { 15 | "recommended": true 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export type { NostrSigner } from "./interface"; 2 | export { Nip07ExtensionSigner, type Nip07Extension } from "./nip07"; 3 | export { Nip46RemoteSigner } from "./nip46/signer"; 4 | export type { 5 | Nip46ClientMetadata, 6 | Nip46ConnectionParams, 7 | Nip46SessionState, 8 | Nip46RemoteSignerOptions, 9 | Nip46RemoteSignerConnectOptions, 10 | } from "./nip46/signer"; 11 | export { SecretKeySigner } from "./secret_key"; 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | 13 | - package-ecosystem: "github-actions" 14 | directory: "/" 15 | schedule: 16 | interval: "weekly" 17 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.Config} */ 2 | const config = { 3 | env: { 4 | node: true, 5 | es2021: true, 6 | }, 7 | parserOptions: { 8 | sourceType: "module", 9 | }, 10 | ignorePatterns: [".eslintrc.js"], 11 | extends: ["eslint:recommended", "prettier"], 12 | overrides: [ 13 | { 14 | files: ["**/*.ts"], 15 | parser: "@typescript-eslint/parser", 16 | plugins: ["@typescript-eslint"], 17 | extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"], 18 | rules: { 19 | "@typescript-eslint/no-unused-vars": [ 20 | "warn", 21 | { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, 22 | ], 23 | }, 24 | }, 25 | ], 26 | rules: { 27 | "no-constant-condition": ["error", { checkLoops: false }], 28 | }, 29 | }; 30 | 31 | module.exports = config; 32 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | test: 10 | name: Lint & test on Node ${{ matrix.node }} 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node: ["18.x", "20.x", "21.x"] 15 | steps: 16 | - name: Checkout repo 17 | uses: actions/checkout@v6 18 | 19 | - name: Setup node ${{ matrix.node }} 20 | uses: actions/setup-node@v6 21 | with: 22 | node-version: ${{ matrix.node }} 23 | 24 | - name: Install dependencies 25 | run: npm ci 26 | 27 | - name: Lint 28 | run: npm run lint 29 | 30 | - name: Test 31 | run: npm run test 32 | 33 | typos: 34 | name: Detect typos 35 | runs-on: ubuntu-latest 36 | steps: 37 | - name: Checkout repo 38 | uses: actions/checkout@v6 39 | 40 | - name: Run typos 41 | uses: crate-ci/typos@v1.39.2 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 jiftechnify 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nostr-signer-connector", 3 | "version": "0.6.0", 4 | "type": "module", 5 | "main": "./dist/index.mjs", 6 | "types": "./dist/index.d.ts", 7 | "exports": { 8 | "types": "./dist/index.d.ts", 9 | "module": "./dist/index.mjs", 10 | "import": "./dist/index.mjs", 11 | "require": "./dist/index.cjs" 12 | }, 13 | "files": [ 14 | "dist", 15 | "src" 16 | ], 17 | "repository": "ssh://git@github.com/jiftechnify/nostr-signer-connector.git", 18 | "author": "jiftechnify ", 19 | "license": "MIT", 20 | "scripts": { 21 | "prepack": "npm run build", 22 | "tsc": "tsc", 23 | "lint": "run-p tsc lint:*", 24 | "lint:format": "biome format ./src", 25 | "lint:js": "biome lint ./src", 26 | "fix": "run-s fix:*", 27 | "fix:format": "biome format --write ./src", 28 | "fix:js": "biome lint --apply ./src", 29 | "test": "vitest run", 30 | "build": "node build.js", 31 | "bump-version": "lerna version", 32 | "release": "lerna publish from-package" 33 | }, 34 | "dependencies": { 35 | "@noble/hashes": "1.5.0", 36 | "nostr-tools": "2.10.1", 37 | "rx-nostr": "^3.4.0", 38 | "rx-nostr-crypto": "^3.1.2" 39 | }, 40 | "devDependencies": { 41 | "@biomejs/biome": "1.9.4", 42 | "@lerna-lite/cli": "^3.2.0", 43 | "@lerna-lite/publish": "^3.2.0", 44 | "@lerna-lite/version": "^3.2.0", 45 | "@tsconfig/strictest": "^2.0.2", 46 | "@types/fs-extra": "^11.0.4", 47 | "esbuild": "^0.24.0", 48 | "fs-extra": "^11.2.0", 49 | "npm-run-all2": "^7.0.1", 50 | "tsx": "^4.6.2", 51 | "typescript": "^5.3.3", 52 | "vitest": "^2.1.5" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import { bytesToHex, hexToBytes } from "@noble/hashes/utils"; 2 | import * as nip19 from "nostr-tools/nip19"; 3 | 4 | const regexHexKey = /^[0-9a-f]{64}$/; 5 | 6 | /** 7 | * Parses the given secret key of any string format (hex/bech32) 8 | * @returns Secret key as both a hex string and an array of bytes, or undefined if the input is invalid 9 | */ 10 | export const parseSecKey = (secKey: string): { hex: string; bytes: Uint8Array } | undefined => { 11 | if (secKey.startsWith("nsec1")) { 12 | const bytes = nip19.decode(secKey as `nsec1${string}`).data; 13 | return { 14 | hex: bytesToHex(bytes), 15 | bytes, 16 | }; 17 | } 18 | if (regexHexKey.test(secKey)) { 19 | return { 20 | hex: secKey, 21 | bytes: hexToBytes(secKey), 22 | }; 23 | } 24 | return undefined; 25 | }; 26 | 27 | /** 28 | * Parses the given public key of any string format (hex/bech32) as hex 29 | * @returns Public key as a hex string, or undefined if the input is invalid 30 | */ 31 | export const parsePubkey = (pubkey: string): string | undefined => { 32 | if (pubkey.startsWith("npub1")) { 33 | return nip19.decode(pubkey as `npub1${string}`).data; 34 | } 35 | if (regexHexKey.test(pubkey)) { 36 | return pubkey; 37 | } 38 | return undefined; 39 | }; 40 | 41 | /** 42 | * Current Unix timestamp in seconds. 43 | */ 44 | export const currentUnixtimeSec = () => Math.floor(Date.now() / 1000); 45 | 46 | export const generateRandomString = () => Math.random().toString(32).substring(2, 8); 47 | 48 | export interface Deferred { 49 | resolve(v: T | PromiseLike): void; 50 | reject(e?: unknown): void; 51 | } 52 | 53 | // biome-ignore lint/suspicious/noUnsafeDeclarationMerging: 54 | export class Deferred { 55 | promise: Promise; 56 | constructor() { 57 | this.promise = new Promise((resolve, reject) => { 58 | this.resolve = (v) => { 59 | resolve(v); 60 | }; 61 | this.reject = (e) => { 62 | reject(e); 63 | }; 64 | }); 65 | } 66 | } 67 | 68 | export const delay = (durationMs: number) => new Promise((resolve) => setTimeout(resolve, durationMs)); 69 | 70 | export const mergeOptionsWithDefaults = (defaults: Required, opts: T): Required => ({ ...defaults, ...opts }); 71 | -------------------------------------------------------------------------------- /src/nip46/signer.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { parseBunkerToken } from "./signer"; 3 | 4 | const testPubkey = { 5 | hex: "d1d1747115d16751a97c239f46ec1703292c3b7e9988b9ebdd4ec4705b15ed44", 6 | npub: "npub168ghgug469n4r2tuyw05dmqhqv5jcwm7nxytn67afmz8qkc4a4zqsu2dlc", 7 | }; 8 | 9 | describe("parseBunkerToken", () => { 10 | describe("should parse bunker:// token", () => { 11 | test("minimal", () => { 12 | const { remotePubkey, relayUrls, secretToken } = parseBunkerToken( 13 | `bunker://${testPubkey.npub}?relay=wss%3A%2F%2Fyabu.me&relay=wss%3A%2F%2Fnrelay.c-stellar.net`, 14 | ); 15 | expect(remotePubkey).toBe(testPubkey.hex); 16 | expect(relayUrls).toEqual(["wss://yabu.me", "wss://nrelay.c-stellar.net"]); 17 | expect(secretToken).toBeUndefined(); 18 | }); 19 | test("with secret", () => { 20 | const { remotePubkey, relayUrls, secretToken } = parseBunkerToken( 21 | `bunker://${testPubkey.npub}?relay=wss%3A%2F%2Fyabu.me&relay=wss%3A%2F%2Fnrelay.c-stellar.net&secret=123456`, 22 | ); 23 | expect(remotePubkey).toBe(testPubkey.hex); 24 | expect(relayUrls).toEqual(["wss://yabu.me", "wss://nrelay.c-stellar.net"]); 25 | expect(secretToken).toBe("123456"); 26 | }); 27 | }); 28 | 29 | describe("should throw error when invalid connection token", () => { 30 | test("invalid schema", () => { 31 | expect(() => { 32 | parseBunkerToken( 33 | `invalid://${testPubkey.npub}?relay=wss%3A%2F%2Fyabu.me&relay=wss%3A%2F%2Fnrelay.c-stellar.net&secret=123456`, 34 | ); 35 | }).toThrowError(); 36 | }); 37 | test("invalid pubkey", () => { 38 | expect(() => { 39 | parseBunkerToken("bunker://hoge"); 40 | }).toThrowError(); 41 | }); 42 | test("no parameters", () => { 43 | expect(() => { 44 | parseBunkerToken(`bunker://${testPubkey.npub}`); 45 | }).toThrowError(); 46 | }); 47 | test("no relay URLs", () => { 48 | expect(() => { 49 | parseBunkerToken(`bunker://${testPubkey.npub}?secret=123456`); 50 | }).toThrowError(); 51 | }); 52 | test("legacy token format", () => { 53 | expect(() => { 54 | parseBunkerToken(`${testPubkey.hex}#123456?relay=wss%3A%2F%2Fyabu.me&relay=wss%3A%2F%2Fnrelay.c-stellar.net`); 55 | }).toThrowError(); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/interface.ts: -------------------------------------------------------------------------------- 1 | import type { Event as NostrEvent, EventTemplate as NostrEventTemplate } from "nostr-tools"; 2 | 3 | /** 4 | * The common interface of Nostr event signer. 5 | */ 6 | export type NostrSigner = { 7 | /** 8 | * Returns the public key that corresponds to the underlying secret key, in hex string format. 9 | */ 10 | getPublicKey(): Promise; 11 | 12 | /** 13 | * Returns the list of relays preferred by the user. 14 | * 15 | * Each entry is a mapping from the relay URL to the preferred use (read/write) of the relay. 16 | */ 17 | getRelays(): Promise; 18 | 19 | /** 20 | * Signs a given Nostr event with the underlying secret key. 21 | * 22 | * @param event a Nostr event template (unsigned event) 23 | * @returns a Promise that resolves to a signed Nostr event 24 | */ 25 | signEvent(event: NostrEventTemplate): Promise; 26 | 27 | /** 28 | * Encrypts a given text to secretly communicate with others, by the encryption algorithm defined in [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md). 29 | * 30 | * @param recipientPubkey a public key of a message recipient, in hex string format 31 | * @param plaintext a plaintext to encrypt 32 | * @returns a Promise that resolves to a encrypted text 33 | */ 34 | nip04Encrypt(recipientPubkey: string, plaintext: string): Promise; 35 | 36 | /** 37 | * Decrypts a given ciphertext from others, by the decryption algorithm defined in [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md). 38 | * 39 | * @param senderPubkey a public key of a message sender, in hex string format 40 | * @param ciphertext a ciphertext to decrypt 41 | * @returns a Promise that resolves to a decrypted text 42 | */ 43 | nip04Decrypt(senderPubkey: string, ciphertext: string): Promise; 44 | 45 | /** 46 | * Encrypts a given text to secretly communicate with others, by the encryption algorithm defined in [NIP-44](https://github.com/nostr-protocol/nips/blob/master/44.md). 47 | * @param recipientPubkey a public key of a message recipient, in hex string format 48 | * @param plaintext a plaintext to encrypt 49 | */ 50 | nip44Encrypt(recipientPubkey: string, plaintext: string): Promise; 51 | 52 | /** 53 | * Decrypts a given ciphertext from others, by the decryption algorithm defined in [NIP-44](https://github.com/nostr-protocol/nips/blob/master/44.md). 54 | * @param senderPubkey a public key of a message sender, in hex string format 55 | * @param ciphertext a ciphertext to decrypt 56 | */ 57 | nip44Decrypt(senderPubkey: string, ciphertext: string): Promise; 58 | }; 59 | 60 | export type RelayList = { 61 | [relayUrl: string]: { read: boolean; write: boolean }; 62 | }; 63 | -------------------------------------------------------------------------------- /src/nip46/relay_pool.ts: -------------------------------------------------------------------------------- 1 | import type { Filter, NostrEvent } from "nostr-tools"; 2 | import { type RxNostr, createRxForwardReq, createRxNostr, uniq } from "rx-nostr"; 3 | import { verifier } from "rx-nostr-crypto"; 4 | import { currentUnixtimeSec, delay } from "../helpers"; 5 | 6 | export type RelayPool = { 7 | // start to subscribe events 8 | subscribe(filter: Filter, onEvent: (ev: NostrEvent) => void): () => void; 9 | // try to publish a Nostr event and wait for at least one OK response 10 | publish(ev: NostrEvent): Promise; 11 | // try to reconnect to all relays 12 | reconnectAll(): void; 13 | // dispose the relay pool 14 | dispose(): void; 15 | }; 16 | 17 | type TryPubResult = 18 | | { 19 | status: "ok"; 20 | } 21 | | { 22 | status: "timeout"; 23 | } 24 | | { 25 | status: "error"; 26 | reason: string; 27 | }; 28 | 29 | export class RxNostrRelayPool implements RelayPool { 30 | #rxn: RxNostr; 31 | #relayUrls: string[]; 32 | 33 | constructor(relayUrls: string[]) { 34 | this.#relayUrls = relayUrls; 35 | 36 | const rxn = createRxNostr({ verifier, skipFetchNip11: true, connectionStrategy: "lazy-keep" }); 37 | rxn.setDefaultRelays(relayUrls); 38 | 39 | rxn.createConnectionStateObservable().subscribe(({ from: rurl, state }) => { 40 | console.debug(`[Nip46RemoteSigner] ${rurl}: connection state changed to ${state}`); 41 | }); 42 | 43 | this.#rxn = rxn; 44 | } 45 | 46 | subscribe(filter: Filter, onEvent: (ev: NostrEvent) => void): () => void { 47 | const req = createRxForwardReq(); 48 | const sub = this.#rxn 49 | .use(req) 50 | .pipe(uniq()) 51 | .subscribe(({ event }) => onEvent(event)); 52 | 53 | req.emit({ ...filter, since: currentUnixtimeSec }); 54 | return () => sub.unsubscribe(); 55 | } 56 | 57 | async publish(ev: NostrEvent): Promise { 58 | const maxRetry = 3; 59 | let retry = 0; 60 | 61 | while (true) { 62 | if (retry === maxRetry) { 63 | throw Error("failed to publish: timed out multiple times and max retry count exceeded"); 64 | } 65 | const res = await this.#tryPub(ev, 3000); 66 | switch (res.status) { 67 | case "ok": 68 | return; 69 | 70 | case "error": 71 | throw Error(`failed to publish event: ${res.reason}`); 72 | 73 | case "timeout": 74 | await delay((1 << retry) * 1000); 75 | retry++; 76 | } 77 | } 78 | } 79 | 80 | // try to publish event, and wait for at least one OK response 81 | async #tryPub(ev: NostrEvent, timeoutMs: number): Promise { 82 | return new Promise((resolve) => { 83 | try { 84 | const timeoutSig = AbortSignal.timeout(timeoutMs); 85 | timeoutSig.addEventListener("abort", () => { 86 | okSub.unsubscribe(); 87 | resolve({ status: "timeout" }); 88 | }); 89 | const okSub = this.#rxn.send(ev).subscribe(({ ok, notice }) => { 90 | if (ok) { 91 | resolve({ status: "ok" }); 92 | } else { 93 | resolve({ status: "error", reason: notice ?? "(empty reason)" }); 94 | } 95 | }); 96 | } catch (err) { 97 | if (err instanceof Error) { 98 | resolve({ status: "error", reason: err.message }); 99 | } else { 100 | resolve({ status: "error", reason: "(unknown error)" }); 101 | } 102 | } 103 | }); 104 | } 105 | 106 | reconnectAll(): void { 107 | this.#relayUrls.map((rurl) => { 108 | this.#rxn.reconnect(rurl); 109 | }); 110 | } 111 | 112 | dispose(): void { 113 | this.#rxn.dispose(); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/secret_key.ts: -------------------------------------------------------------------------------- 1 | import { bytesToHex } from "@noble/hashes/utils"; 2 | import type { Event as NostrEvent, EventTemplate as NostrEventTemplate } from "nostr-tools"; 3 | import * as nip04 from "nostr-tools/nip04"; 4 | import * as nip44 from "nostr-tools/nip44"; 5 | import * as nip49 from "nostr-tools/nip49"; 6 | import { finalizeEvent, generateSecretKey, getPublicKey as pubkeyFromSeckeyBytes } from "nostr-tools/pure"; 7 | import { parseSecKey } from "./helpers"; 8 | import type { NostrSigner, RelayList } from "./interface"; 9 | 10 | /** 11 | * An implementation of NostrSigner based on a bare secret key in memory. 12 | * 13 | * You can create a SecretKeySigner in the following ways: 14 | * 15 | * - via the constructor (`new SecretKeySigner(key)`), from secret keys in hex string, bech32 (`nsec1...`) or binary format. 16 | * - via `SecretKeySigner.fromEncryptedKey()`, from a NIP-49 encrypted secret key (`ncryptsec...`) and a password for it. 17 | * - via `SecretKeySigner.withRandomKey()`, to make a signer with a random key. 18 | */ 19 | export class SecretKeySigner implements NostrSigner { 20 | #seckeyHex: string; 21 | #seckeyBytes: Uint8Array; 22 | 23 | /** 24 | * Creates a SecretKeySigner from a secret key in hex string or bech32 (`nsec1...`) format. 25 | * 26 | * @param secKeyStr a secret key in hex string or bech32 format 27 | */ 28 | public constructor(secKeyStr: string); 29 | 30 | /** 31 | * Creates a SecretKeySigner from a secret key in binary format. 32 | * 33 | * @param secKeyBytes a secret key in binary format (`Uint8Array`) 34 | */ 35 | public constructor(secKeyBytes: Uint8Array); 36 | 37 | public constructor(secKey: string | Uint8Array) { 38 | if (typeof secKey === "string") { 39 | const res = parseSecKey(secKey); 40 | if (res === undefined) { 41 | throw Error("SecretKeySigner: constructor got an invalid secret key"); 42 | } 43 | this.#seckeyHex = res.hex; 44 | this.#seckeyBytes = res.bytes; 45 | } else { 46 | // secret key must be 32 bytes length. 47 | if (secKey.length !== 32) { 48 | throw Error("SecretKeySigner: constructor got an invalid secret key"); 49 | } 50 | this.#seckeyBytes = secKey; 51 | this.#seckeyHex = bytesToHex(secKey); 52 | } 53 | } 54 | 55 | /** 56 | * Creates a SecretKeySigner from a NIP-49 encrypted secret key (`ncryptsec...`) and a password. 57 | * 58 | * @param ncryptsec NIP-49 encrypted secret key 59 | * @param password password to decrypt the secret key 60 | */ 61 | public static fromEncryptedKey(ncryptsec: string, password: string) { 62 | return new SecretKeySigner(nip49.decrypt(ncryptsec, password)); 63 | } 64 | 65 | /** 66 | * Creates a SecretKeySigner with a random secret key. 67 | */ 68 | public static withRandomKey(): SecretKeySigner { 69 | return new SecretKeySigner(generateSecretKey()); 70 | } 71 | 72 | /** 73 | * Returns the underlying secret key in hex string format. 74 | */ 75 | public get secretKey(): string { 76 | return this.#seckeyHex; 77 | } 78 | 79 | /** 80 | * Returns the public key that corresponds to the underlying secret key, in hex string format. 81 | */ 82 | public get publicKey(): string { 83 | return pubkeyFromSeckeyBytes(this.#seckeyBytes); 84 | } 85 | 86 | /** 87 | * Returns the public key that corresponds to the underlying secret key, in hex string format. 88 | */ 89 | public async getPublicKey(): Promise { 90 | return this.publicKey; 91 | } 92 | 93 | /** 94 | * Returns the list of relays preferred by the user. 95 | * 96 | * `getRelays()` on `SecretKeySigner` actually returns an empty list because it doesn't have any information of user preferences about relays. 97 | */ 98 | public async getRelays(): Promise { 99 | return {}; 100 | } 101 | 102 | /** 103 | * Signs a given Nostr event with the underlying secret key. 104 | * 105 | * @param event a Nostr event template (unsigned event) 106 | * @returns a Promise that resolves to a signed Nostr event 107 | */ 108 | public async signEvent(event: NostrEventTemplate): Promise { 109 | return Promise.resolve(finalizeEvent(event, this.#seckeyBytes)); 110 | } 111 | 112 | /** 113 | * Encrypts a given text to secretly communicate with others, by the encryption algorithm defined in [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md). 114 | * 115 | * @param recipientPubkey a public key of a message recipient, in hex string format 116 | * @param plaintext a plaintext to encrypt 117 | * @returns a Promise that resolves to a encrypted text 118 | */ 119 | public async nip04Encrypt(recipientPubkey: string, plaintext: string): Promise { 120 | return nip04.encrypt(this.#seckeyHex, recipientPubkey, plaintext); 121 | } 122 | 123 | /** 124 | * Decrypts a given ciphertext from others, by the decryption algorithm defined in [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md). 125 | * 126 | * @param senderPubkey a public key of a message sender, in hex string format 127 | * @param ciphertext a ciphertext to decrypt 128 | * @returns a Promise that resolves to a decrypted text 129 | */ 130 | public async nip04Decrypt(senderPubkey: string, ciphertext: string): Promise { 131 | return nip04.decrypt(this.#seckeyHex, senderPubkey, ciphertext); 132 | } 133 | 134 | /** 135 | * Encrypts a given text to secretly communicate with others, by the encryption algorithm defined in [NIP-44](https://github.com/nostr-protocol/nips/blob/master/44.md). 136 | * 137 | * @param recipientPubkey a public key of a message recipient, in hex string format 138 | * @param plaintext a plaintext to encrypt 139 | * @returns a Promise that resolves to a encrypted text 140 | */ 141 | public async nip44Encrypt(recipientPubkey: string, plaintext: string): Promise { 142 | const convKey = nip44.v2.utils.getConversationKey(this.#seckeyBytes, recipientPubkey); 143 | return nip44.v2.encrypt(plaintext, convKey); 144 | } 145 | 146 | /** 147 | * Decrypts a given ciphertext from others, by the decryption algorithm defined in [NIP-44](https://github.com/nostr-protocol/nips/blob/master/44.md). 148 | * 149 | * @param senderPubkey a public key of a message sender, in hex string format 150 | * @param ciphertext a ciphertext to decrypt 151 | * @returns a Promise that resolves to a decrypted text 152 | */ 153 | public async nip44Decrypt(senderPubkey: string, ciphertext: string): Promise { 154 | const convkey = nip44.v2.utils.getConversationKey(this.#seckeyBytes, senderPubkey); 155 | return nip44.v2.decrypt(ciphertext, convkey); 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /.github/workflows/combine-prs.yml: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020 Hrvey 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in all 11 | # copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | # SOFTWARE. 20 | 21 | name: 'Combine Dependabot PRs' 22 | 23 | # Controls when the action will run - in this case triggered manually 24 | on: 25 | workflow_dispatch: 26 | inputs: 27 | branchPrefix: 28 | description: 'Branch prefix to find combinable PRs based on' 29 | required: true 30 | default: 'dependabot' 31 | mustBeGreen: 32 | description: 'Only combine PRs that are green (status is success)' 33 | required: true 34 | default: true 35 | type: boolean 36 | combineBranchName: 37 | description: 'Name of the branch to combine PRs into' 38 | required: true 39 | default: 'combined-dependabot-prs' 40 | ignoreLabel: 41 | description: 'Exclude PRs with this label' 42 | required: true 43 | default: 'nocombine' 44 | 45 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 46 | jobs: 47 | # This workflow contains a single job called "combine-prs" 48 | combine-prs: 49 | # The type of runner that the job will run on 50 | runs-on: ubuntu-latest 51 | 52 | # Steps represent a sequence of tasks that will be executed as part of the job 53 | steps: 54 | - uses: actions/github-script@v7 55 | id: create-combined-pr 56 | name: Create Combined PR 57 | with: 58 | github-token: ${{secrets.GITHUB_TOKEN}} 59 | script: | 60 | const pulls = await github.paginate('GET /repos/:owner/:repo/pulls', { 61 | owner: context.repo.owner, 62 | repo: context.repo.repo 63 | }); 64 | let branchesAndPRStrings = []; 65 | let baseBranch = null; 66 | let baseBranchSHA = null; 67 | for (const pull of pulls) { 68 | const branch = pull['head']['ref']; 69 | console.log('Pull for branch: ' + branch); 70 | if (branch.startsWith('${{ github.event.inputs.branchPrefix }}')) { 71 | console.log('Branch matched prefix: ' + branch); 72 | let statusOK = true; 73 | if(${{ github.event.inputs.mustBeGreen }}) { 74 | console.log('Checking green status: ' + branch); 75 | const stateQuery = `query($owner: String!, $repo: String!, $pull_number: Int!) { 76 | repository(owner: $owner, name: $repo) { 77 | pullRequest(number:$pull_number) { 78 | commits(last: 1) { 79 | nodes { 80 | commit { 81 | statusCheckRollup { 82 | state 83 | } 84 | } 85 | } 86 | } 87 | } 88 | } 89 | }` 90 | const vars = { 91 | owner: context.repo.owner, 92 | repo: context.repo.repo, 93 | pull_number: pull['number'] 94 | }; 95 | const result = await github.graphql(stateQuery, vars); 96 | const [{ commit }] = result.repository.pullRequest.commits.nodes; 97 | const state = commit.statusCheckRollup.state 98 | console.log('Validating status: ' + state); 99 | if(state != 'SUCCESS') { 100 | console.log('Discarding ' + branch + ' with status ' + state); 101 | statusOK = false; 102 | } 103 | } 104 | console.log('Checking labels: ' + branch); 105 | const labels = pull['labels']; 106 | for(const label of labels) { 107 | const labelName = label['name']; 108 | console.log('Checking label: ' + labelName); 109 | if(labelName == '${{ github.event.inputs.ignoreLabel }}') { 110 | console.log('Discarding ' + branch + ' with label ' + labelName); 111 | statusOK = false; 112 | } 113 | } 114 | if (statusOK) { 115 | console.log('Adding branch to array: ' + branch); 116 | const prString = '#' + pull['number'] + ' ' + pull['title']; 117 | branchesAndPRStrings.push({ branch, prString }); 118 | baseBranch = pull['base']['ref']; 119 | baseBranchSHA = pull['base']['sha']; 120 | } 121 | } 122 | } 123 | if (branchesAndPRStrings.length == 0) { 124 | core.setFailed('No PRs/branches matched criteria'); 125 | return; 126 | } 127 | try { 128 | await github.rest.git.createRef({ 129 | owner: context.repo.owner, 130 | repo: context.repo.repo, 131 | ref: 'refs/heads/' + '${{ github.event.inputs.combineBranchName }}', 132 | sha: baseBranchSHA 133 | }); 134 | } catch (error) { 135 | console.log(error); 136 | core.setFailed('Failed to create combined branch - maybe a branch by that name already exists?'); 137 | return; 138 | } 139 | 140 | let combinedPRs = []; 141 | let mergeFailedPRs = []; 142 | for(const { branch, prString } of branchesAndPRStrings) { 143 | try { 144 | await github.rest.repos.merge({ 145 | owner: context.repo.owner, 146 | repo: context.repo.repo, 147 | base: '${{ github.event.inputs.combineBranchName }}', 148 | head: branch, 149 | }); 150 | console.log('Merged branch ' + branch); 151 | combinedPRs.push(prString); 152 | } catch (error) { 153 | console.log('Failed to merge branch ' + branch); 154 | mergeFailedPRs.push(prString); 155 | } 156 | } 157 | 158 | console.log('Creating combined PR'); 159 | const combinedPRsString = combinedPRs.join('\n'); 160 | let body = '✅ This PR was created by the Combine PRs action by combining the following PRs:\n' + combinedPRsString; 161 | if(mergeFailedPRs.length > 0) { 162 | const mergeFailedPRsString = mergeFailedPRs.join('\n'); 163 | body += '\n\n⚠️ The following PRs were left out due to merge conflicts:\n' + mergeFailedPRsString 164 | } 165 | await github.rest.pulls.create({ 166 | owner: context.repo.owner, 167 | repo: context.repo.repo, 168 | title: 'Combined Dependabot PRs', 169 | head: '${{ github.event.inputs.combineBranchName }}', 170 | base: baseBranch, 171 | body: body 172 | }); 173 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nostr-signer-connector 2 | 3 | A library that allows Nostr clients to interact with various _Nostr event 4 | signers_ in [a uniform manner](#the-nostrsigner-interface). 5 | 6 | Currently this library supports 3 types of Nostr event signers: 7 | 8 | - Signers based on a bare secret key (`SecretKeySigner`) 9 | - [NIP-07](https://github.com/nostr-protocol/nips/blob/master/07.md) browser 10 | extensions (`Nip07ExtensionSigner`) 11 | - [NIP-46](https://github.com/nostr-protocol/nips/blob/master/46.md) remote 12 | signer a.k.a. Nostr Connect / [nsecBunker](https://nsecbunker.com/) 13 | (`Nip46RemoteSigner`) 14 | - Both "Initiated by the remote-signer" and "Initiated by the client" 15 | connection flows are supported! 16 | 17 | ## Installation 18 | 19 | ``` 20 | npm install nostr-signer-connector 21 | ``` 22 | 23 | ## Usage: How to Initialize Signer Instances 24 | 25 | ### `SecretKeySigner` 26 | 27 | Signers based on a bare secret key on memory. 28 | 29 | You can create it from a secret (private) key in various format using the 30 | constructor: 31 | 32 | - Hex string 33 | - Bech32-encoded key (`nsec1...`) 34 | - 32 bytes of binary data (in a `Uint8Array`) 35 | 36 | It also supports importing 37 | [NIP-49](https://github.com/nostr-protocol/nips/blob/master/49.md) encrypted 38 | secret key (`ncryptsec...`) via `SecretKeySigner.fromEncryptedKey`. 39 | 40 | ```ts 41 | import { SecretKeySigner } from 'nostr-signer-connector'; 42 | 43 | // from hex string 44 | const hexkey = "deadbeef..."; 45 | const signer1 = new SecretKeySigner(hexkey); 46 | 47 | // from nsec 48 | const nsec = "nsec1..."; 49 | const signer2 = new SecretKeySigner(nsec); 50 | 51 | // from binary 52 | const binkey: Uint8Array = ...; 53 | const signer3 = new SecretKeySigner(binkey); 54 | 55 | // from NIP-49 encrypted key 56 | const ncryptsec = "ncryptsec..."; 57 | const password = "???"; 58 | const signer4 = new SecretKeySigner.fromEncryptedKey(ncryptsec, password); 59 | ``` 60 | 61 | ### `Nip07ExtensionSigner` 62 | 63 | Signers based on a 64 | [NIP-07](https://github.com/nostr-protocol/nips/blob/master/07.md) browser 65 | extension. 66 | 67 | You can create it by passing a `window.nostr` instance to the constructor. 68 | 69 | ```ts 70 | import { 71 | type Nip07Extension, 72 | Nip07ExtensionSigner, 73 | } from "nostr-signer-connector"; 74 | 75 | const signer = new Nip07ExtensionSigner(window.nostr as Nip07Extension); 76 | ``` 77 | 78 | ### `Nip46RemoteSigner` 79 | 80 | Signers based on a 81 | [NIP-46](https://github.com/nostr-protocol/nips/blob/master/46.md) remote signer 82 | (a.k.a. Nostr Connect or nsecBunker). 83 | 84 | You **can't** create `Nip46RemoteSigner` instances using the constructor. You 85 | should use static initialization methods on `Nip46RemoteSigner` class, 86 | appropriate for type of remote signer you want to support. 87 | 88 | #### Connect to a remote signer ("Initiated by remote-signer" flow) 89 | 90 | **Use `Nip46RemoteSigner.connectToRemote()`**. 91 | 92 | It tries to connect to a remote signer by passing the connection token generated 93 | by the signer 94 | (`bunker://?relay=wss://...&secret=`), and 95 | establishes a session to the signer. 96 | 97 | If it succeeded to connect, the returned promise resolves to an object that have 98 | session data (`session`) along with a handle to the signer (`signer`). **You 99 | should store this session data in somewhere** to resume the session later (e.g. 100 | after a browser reload). 101 | 102 | ```ts 103 | import { Nip46RemoteSigner } from 'nostr-signer-connector'; 104 | 105 | const connToken = "bunker://deadbeef...?relay=wss%3A%2F%2Frelay.nsecbunker.com&secret=..." 106 | const { signer, session } = await Nip46RemoteSigner.connectToRemote(connToken); 107 | 108 | // store session data to LocalStorage 109 | localStorage.setItem("nostr_connect_session", JSON.stringify(session)); 110 | 111 | // use the signer as you want... 112 | const ev = await signer.signEvent({...}); 113 | ``` 114 | 115 | #### Listen connection from a remote signer ("Initiated by the client" flow) 116 | 117 | **Use `Nip46RemoteSigner.listenConnectionFromRemote()`**. 118 | 119 | It starts to listen a connection request from a remote signer, and establishes a 120 | session to the signer once a connection request is received. 121 | 122 | First of all, calling the method generates a connection token for a remote 123 | signer (`nostrconnect://...`) which allows the signer to send connection request 124 | to your app. You should show the URI to users in some way, and instruct them to 125 | paste it on their remote signer. 126 | 127 | You can wait until a session is established by `await`-ing on `established` 128 | property of the return value. This promise resolves to an object that have 129 | session data (`session`) along with a handle to the remote signer (`signer`). 130 | **You should store this session data in somewhere** to resume the session later 131 | (e.g. after a browser reload). 132 | 133 | ```ts 134 | import { Nip46RemoteSigner, type Nip46ClientMetadata } from 'nostr-signer-connector'; 135 | 136 | const relayUrls = ["wss://relay.nsec.app/"]; 137 | const client: Nip46ClientMetadata = { 138 | name: "sample client", 139 | url: "https://example.com", 140 | description: "just a sample" 141 | }; 142 | const { connectUri, established } = 143 | Nip46RemoteSigner.listenConnectionFromRemote(relayUrls, client); 144 | 145 | // show the connect URI to user 146 | console.log("paste this URI on Nostr Connect signer:", connectUri); 147 | 148 | // wait until a session to a remote signer is established... 149 | const { signer, session } = await established; 150 | 151 | // store session data to LocalStorage 152 | localStorage.setItem("nostr_connect_session", JSON.stringify(session)); 153 | 154 | // use the signer as you want... 155 | const ev = await signer.signEvent({...}); 156 | ``` 157 | 158 | #### Resume a session to a remote signer 159 | 160 | Once a session to a remote signer have been established by initialization 161 | methods above, you can resume the session by passing a stored session data to 162 | **`Nip46RemoteSigner.resumeSession()`**. 163 | 164 | ```ts 165 | import { Nip46RemoteSigner, type Nip46SessionState } from 'nostr-signer-connector'; 166 | 167 | const rawSess = localStorage.getItem("nostr_connect_session") 168 | if (rawSess === null) { 169 | // session not stored: start session by methods above 170 | } 171 | 172 | const sess = JSON.parse(rawSess) as Nip46SessionState; 173 | const signer = await Nip46RemoteSigner.resumeSession(sess); 174 | 175 | // use the signer as you want... 176 | const ev = await signer.signEvent({...}); 177 | ``` 178 | 179 | ## The `NostrSigner` Interface 180 | 181 | All signer implementations share common interface as follows: 182 | 183 | ```ts 184 | /** 185 | * The common interface of Nostr event signer. 186 | */ 187 | type NostrSigner = { 188 | /** 189 | * Returns the public key that corresponds to the underlying secret key, in hex string format. 190 | */ 191 | getPublicKey(): Promise; 192 | 193 | /** 194 | * Returns the list of relays preferred by the user. 195 | */ 196 | getRelays(): Promise<{ [relayUrl: string]: { read: boolean; write: boolean } }>; 197 | 198 | /** 199 | * Signs a given Nostr event with the underlying secret key. 200 | */ 201 | signEvent(event: NostrEventTemplate): Promise; 202 | 203 | /** 204 | * Encrypts a given text to secretly communicate with others, by the encryption algorithm defined in NIP-04. 205 | */ 206 | nip04Encrypt(recipientPubkey: string, plaintext: string): Promise; 207 | 208 | /** 209 | * Decrypts a given ciphertext from others, by the decryption algorithm defined in NIP-04. 210 | */ 211 | nip04Decrypt(senderPubkey: string, ciphertext: string): Promise; 212 | 213 | /** 214 | * Encrypts a given text to secretly communicate with others, by the encryption algorithm defined in NIP-44. 215 | */ 216 | nip44Encrypt(recipientPubkey: string, plaintext: string): Promise; 217 | 218 | /** 219 | * Decrypts a given ciphertext from others, by the decryption algorithm defined in NIP-44. 220 | */ 221 | nip44Decrypt(senderPubkey: string, ciphertext: string): Promise; 222 | }; 223 | ``` 224 | -------------------------------------------------------------------------------- /src/nip07.ts: -------------------------------------------------------------------------------- 1 | import type { Event as NostrEvent, EventTemplate as NostrEventTemplate } from "nostr-tools"; 2 | import { Deferred, mergeOptionsWithDefaults } from "./helpers"; 3 | import type { NostrSigner, RelayList } from "./interface"; 4 | 5 | export type Nip07Extension = { 6 | getPublicKey(): Promise; 7 | signEvent(event: NostrEventTemplate): Promise; 8 | getRelays?(): Promise; 9 | nip04?: { 10 | encrypt?(pubKey: string, value: string): Promise; 11 | decrypt?(pubKey: string, value: string): Promise; 12 | }; 13 | nip44?: { 14 | encrypt?(pubKey: string, value: string): Promise; 15 | decrypt?(pubKey: string, value: string): Promise; 16 | }; 17 | }; 18 | 19 | export type Nip07ExtensionSignerOptions = { 20 | /** 21 | * Enables the request queueing. 22 | * Under the request queueing, you can still call methods concurrently, though actually only a single request is executed at a point in time. 23 | * 24 | * This is useful when the NIP-07 extension you use can't process concurrent requests correctly. 25 | * 26 | * @default false 27 | */ 28 | enableQueueing?: boolean; 29 | }; 30 | 31 | const defaultOptions: Required = { 32 | enableQueueing: false, 33 | }; 34 | 35 | /** 36 | * An implementation of NostrSigner based on a [NIP-07](https://github.com/nostr-protocol/nips/blob/master/07.md) browser extension. 37 | * 38 | * NOTE: `nip04/nip44`-`Encrypt/Decrypt` methods throw error if the underlying NIP-07 extension doesn't support them. 39 | */ 40 | export class Nip07ExtensionSigner implements NostrSigner { 41 | #nip07Ext: Nip07Extension; 42 | #reqSerializer: RequestSerializer; 43 | 44 | /** 45 | * Creates a Nip07ExtensionSigner from an instance of NIP-07 browser extension. 46 | * 47 | * @param nip07Ext an instance of NIP-07 extension (`window.nostr`) 48 | */ 49 | public constructor(nip07Ext: Nip07Extension, options: Nip07ExtensionSignerOptions = {}) { 50 | this.#nip07Ext = nip07Ext; 51 | 52 | const { enableQueueing } = mergeOptionsWithDefaults(defaultOptions, options); 53 | if (enableQueueing) { 54 | this.#reqSerializer = new ReqSerializationQueue(); 55 | } else { 56 | this.#reqSerializer = new NoopReqSerializer(); 57 | } 58 | } 59 | 60 | /** 61 | * Returns the public key that corresponds to the underlying secret key, in hex string format. 62 | */ 63 | public async getPublicKey(): Promise { 64 | return this.#reqSerializer.addRequest(() => this.#nip07Ext.getPublicKey()); 65 | } 66 | 67 | /** 68 | * Returns the list of relays preferred by the user. 69 | * 70 | * Each entry is a mapping from the relay URL to the preferred use (read/write) of the relay. 71 | */ 72 | public async getRelays(): Promise { 73 | if (typeof this.#nip07Ext.getRelays !== "function") { 74 | throw Error("NIP-07 browser extension doesn't support getRelays"); 75 | } 76 | // biome-ignore lint/style/noNonNullAssertion: extension's field existence hardly changes during runtime 77 | return this.#reqSerializer.addRequest(() => this.#nip07Ext.getRelays!()); 78 | } 79 | 80 | /** 81 | * Signs a given Nostr event with the underlying secret key. 82 | * 83 | * @param event a Nostr event template (unsigned event) 84 | * @returns a Promise that resolves to a signed Nostr event 85 | */ 86 | public async signEvent(event: NostrEventTemplate): Promise { 87 | return this.#reqSerializer.addRequest(() => this.#nip07Ext.signEvent(event)); 88 | } 89 | 90 | /** 91 | * Encrypts a given text to secretly communicate with others, by the encryption algorithm defined in [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md). 92 | * 93 | * @param recipientPubkey a public key of a message recipient, in hex string format 94 | * @param plaintext a plaintext to encrypt 95 | * @returns a Promise that resolves to a encrypted text 96 | */ 97 | public async nip04Encrypt(recipientPubkey: string, plaintext: string): Promise { 98 | if (typeof this.#nip07Ext.nip04?.encrypt !== "function") { 99 | throw Error("NIP-07 browser extension doesn't support nip04.encrypt"); 100 | } 101 | // biome-ignore lint/style/noNonNullAssertion: extension's field existence hardly changes during runtime 102 | return this.#reqSerializer.addRequest(() => this.#nip07Ext.nip04!.encrypt!(recipientPubkey, plaintext)); 103 | } 104 | 105 | /** 106 | * Decrypts a given ciphertext from others, by the decryption algorithm defined in [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md). 107 | * 108 | * @param senderPubkey a public key of a message sender, in hex string format 109 | * @param ciphertext a ciphertext to decrypt 110 | * @returns a Promise that resolves to a decrypted text 111 | */ 112 | public async nip04Decrypt(senderPubkey: string, ciphertext: string): Promise { 113 | if (typeof this.#nip07Ext.nip04?.decrypt !== "function") { 114 | throw Error("NIP-07 browser extension doesn't support nip04.decrypt"); 115 | } 116 | // biome-ignore lint/style/noNonNullAssertion: extension's field existence hardly changes during runtime 117 | return this.#reqSerializer.addRequest(() => this.#nip07Ext.nip04!.decrypt!(senderPubkey, ciphertext)); 118 | } 119 | 120 | /** 121 | * Encrypts a given text to secretly communicate with others, by the encryption algorithm defined in [NIP-44](https://github.com/nostr-protocol/nips/blob/master/44.md). 122 | * 123 | * @param recipientPubkey a public key of a message recipient, in hex string format 124 | * @param plaintext a plaintext to encrypt 125 | * @returns a Promise that resolves to a encrypted text 126 | */ 127 | public async nip44Encrypt(recipientPubkey: string, plaintext: string): Promise { 128 | if (typeof this.#nip07Ext.nip44?.encrypt !== "function") { 129 | throw Error("NIP-07 browser extension doesn't support nip44.encrypt"); 130 | } 131 | // biome-ignore lint/style/noNonNullAssertion: extension's field existence hardly changes during runtime 132 | return this.#reqSerializer.addRequest(() => this.#nip07Ext.nip44!.encrypt!(recipientPubkey, plaintext)); 133 | } 134 | 135 | /** 136 | * Decrypts a given ciphertext from others, by the decryption algorithm defined in [NIP-44](https://github.com/nostr-protocol/nips/blob/master/44.md). 137 | * 138 | * @param senderPubkey a public key of a message sender, in hex string format 139 | * @param ciphertext a ciphertext to decrypt 140 | * @returns a Promise that resolves to a decrypted text 141 | */ 142 | public async nip44Decrypt(senderPubkey: string, ciphertext: string): Promise { 143 | if (typeof this.#nip07Ext.nip44?.decrypt !== "function") { 144 | throw Error("NIP-07 browser extension doesn't support nip44.decrypt"); 145 | } 146 | // biome-ignore lint/style/noNonNullAssertion: extension's field existence hardly changes during runtime 147 | return this.#reqSerializer.addRequest(() => this.#nip07Ext.nip44!.decrypt!(senderPubkey, ciphertext)); 148 | } 149 | } 150 | 151 | interface RequestSerializer { 152 | addRequest(req: () => Promise): Promise; 153 | } 154 | 155 | class ReqSerializationQueue implements RequestSerializer { 156 | #reqQ: (() => Promise)[] = []; 157 | #running = false; 158 | 159 | public addRequest(req: () => Promise): Promise { 160 | const d = new Deferred(); 161 | const r = async () => { 162 | try { 163 | d.resolve(await req()); 164 | } catch (err) { 165 | d.reject(err); 166 | } 167 | }; 168 | this.#reqQ.push(r); 169 | 170 | if (!this.#running) { 171 | this.#running = true; 172 | this.startLoop(); 173 | } 174 | 175 | return d.promise; 176 | } 177 | 178 | private async startLoop() { 179 | try { 180 | while (true) { 181 | const req = this.#reqQ.shift(); 182 | if (req === undefined) { 183 | break; 184 | } 185 | await req(); 186 | } 187 | } finally { 188 | this.#running = false; 189 | } 190 | } 191 | } 192 | 193 | class NoopReqSerializer implements RequestSerializer { 194 | public addRequest(req: () => Promise): Promise { 195 | return req(); 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /src/nip46/rpc.ts: -------------------------------------------------------------------------------- 1 | import type { NostrEvent, EventTemplate as NostrEventTemplate } from "nostr-tools"; 2 | import { Deferred, currentUnixtimeSec, generateRandomString, mergeOptionsWithDefaults } from "../helpers"; 3 | import type { NostrSigner, RelayList } from "../interface"; 4 | import type { SecretKeySigner } from "../secret_key"; 5 | import type { RelayPool } from "./relay_pool"; 6 | 7 | export type Nip46RpcReq = { 8 | id: string; 9 | method: string; 10 | params: string[]; 11 | }; 12 | 13 | export type Nip46RpcResp = { 14 | id: string; 15 | result?: string | undefined | null; 16 | error?: string | undefined | null; 17 | }; 18 | 19 | type ParsedNip46RpcResp = { id: string } & ( 20 | | { 21 | status: "ok"; 22 | result: string; 23 | } 24 | | { 25 | status: "error"; 26 | error: string; 27 | } 28 | | { 29 | status: "auth"; 30 | authUrl: string; 31 | } 32 | | { 33 | status: "empty"; 34 | } 35 | ); 36 | 37 | const parseNip46RpcResp = async (ev: NostrEvent, signer: NostrSigner): Promise => { 38 | const plainContent = await signer.nip44Decrypt(ev.pubkey, ev.content); 39 | const { id, result, error } = JSON.parse(plainContent) as Nip46RpcResp; 40 | 41 | // there are cases that both `error` and `result` have values, so check error first 42 | if (error != null) { 43 | // if `result` is "auth_url", response should be regarded as an auth challenge. 44 | // in this case, `error` points to a URL for user authentication. 45 | if (result === "auth_url") { 46 | return { id, status: "auth", authUrl: error }; 47 | } 48 | return { id, status: "error", error }; 49 | } 50 | if (result != null) { 51 | return { id, status: "ok", result }; 52 | } 53 | return { id, status: "empty" }; 54 | }; 55 | 56 | type Nip46RpcSignatures = { 57 | connect: { 58 | params: [remotePubkey: string, secret?: string, permissions?: string]; 59 | result: string; 60 | }; 61 | sign_event: { 62 | params: [event: NostrEventTemplate]; 63 | result: NostrEvent; 64 | }; 65 | ping: { 66 | params: []; 67 | result: string; 68 | }; 69 | get_relays: { 70 | params: []; 71 | result: RelayList; 72 | }; 73 | get_public_key: { 74 | params: []; 75 | result: string; 76 | }; 77 | nip04_encrypt: { 78 | params: [remotePubkey: string, plainText: string]; 79 | result: string; 80 | }; 81 | nip04_decrypt: { 82 | params: [remotePubkey: string, cipherText: string]; 83 | result: string; 84 | }; 85 | nip44_encrypt: { 86 | params: [remotePubkey: string, plainText: string]; 87 | result: string; 88 | }; 89 | nip44_decrypt: { 90 | params: [remotePubkey: string, cipherText: string]; 91 | result: string; 92 | }; 93 | }; 94 | 95 | type Nip46RpcMethods = keyof Nip46RpcSignatures; 96 | type Nip46RpcParams = Nip46RpcSignatures[M]["params"]; 97 | type Nip46RpcResult = Nip46RpcSignatures[M]["result"]; 98 | 99 | type Nip46RpcParamsEncoders = { 100 | [M in keyof Nip46RpcSignatures]: (params: Nip46RpcParams) => string[]; 101 | }; 102 | type Nip46RpcResultDecoders = { 103 | [M in keyof Nip46RpcSignatures]: (rawResult: string) => Nip46RpcResult; 104 | }; 105 | 106 | const identity = (v: T) => v; 107 | const nip46RpcParamsEncoders: Nip46RpcParamsEncoders = { 108 | connect: (params) => params as string[], 109 | sign_event: ([ev]) => [JSON.stringify(ev)], 110 | ping: identity, 111 | get_relays: identity, 112 | get_public_key: identity, 113 | nip04_encrypt: identity, 114 | nip04_decrypt: identity, 115 | nip44_encrypt: identity, 116 | nip44_decrypt: identity, 117 | }; 118 | const nip46RpcResultDecoders: Nip46RpcResultDecoders = { 119 | connect: identity, 120 | sign_event: (raw: string) => JSON.parse(raw) as NostrEvent, 121 | ping: identity, 122 | get_relays: (raw: string) => JSON.parse(raw) as RelayList, 123 | get_public_key: identity, 124 | nip04_encrypt: identity, 125 | nip04_decrypt: identity, 126 | nip44_encrypt: identity, 127 | nip44_decrypt: identity, 128 | }; 129 | 130 | export type Nip46RpcClientOptions = { 131 | /** 132 | * The maximum amount of time to wait for a response to a signer operation request, in milliseconds. 133 | * 134 | * @default 15000 135 | */ 136 | requestTimeoutMs?: number; 137 | 138 | /** 139 | * The handler for auth challenge from a remote signer. 140 | * 141 | * Default is just ignoring any auth challenges. 142 | */ 143 | onAuthChallenge?: (authUrl: string) => void; 144 | }; 145 | 146 | export const defaultRpcCliOptions: Required = { 147 | requestTimeoutMs: 15 * 1000, 148 | onAuthChallenge: (_) => { 149 | console.debug("NIP-46 RPC: ignoring auth challenge..."); 150 | }, 151 | }; 152 | 153 | export class Nip46RpcClient { 154 | #localSigner: NostrSigner; 155 | #remotePubkey: string; 156 | #options: Required; 157 | 158 | #relayPool: RelayPool; 159 | #closeSub: (() => void) | undefined = undefined; 160 | 161 | #inflightRpcs: Map> = new Map(); 162 | 163 | constructor( 164 | localSigner: NostrSigner, 165 | remotePubkey: string, 166 | relayPool: RelayPool, 167 | options: Required, 168 | ) { 169 | this.#localSigner = localSigner; 170 | this.#remotePubkey = remotePubkey; 171 | this.#options = options; 172 | this.#relayPool = relayPool; 173 | } 174 | 175 | /** 176 | * Start waiting for a `connect` response from a remote signer. 177 | */ 178 | public static startWaitingForConnectRespFromRemote( 179 | localSigner: SecretKeySigner, 180 | relayPool: RelayPool, 181 | secret: string, 182 | timeoutMs: number, 183 | ): { connected: Promise } { 184 | const respWait = new Deferred(); 185 | 186 | const onEvent = async (ev: NostrEvent) => { 187 | const resp = await parseNip46RpcResp(ev, localSigner); 188 | switch (resp.status) { 189 | case "ok": { 190 | const signerPubkey = ev.pubkey; 191 | if (resp.result === "ack") { 192 | // TODO: approve "ack" for now, but should be rejected in the future 193 | console.warn("NIP-46 RPC: remote signer respondeds with just 'ack'"); 194 | respWait.resolve(signerPubkey); 195 | return; 196 | } 197 | if (resp.result !== secret) { 198 | respWait.reject(new Error("NIP-46 RPC: secret mismatch")); 199 | return; 200 | } 201 | // secret returned from the remote siner matches with the one in connection token! 202 | respWait.resolve(signerPubkey); 203 | return; 204 | } 205 | case "auth": 206 | console.debug("NIP-46 RPC: ignoring auth challenge during waiting for connection from remote..."); 207 | return; 208 | case "error": 209 | respWait.reject(new Error(`NIP-46 RPC resulted in error: ${resp.error}`)); 210 | return; 211 | case "empty": 212 | respWait.reject(new Error("NIP-46 RPC: empty response")); 213 | return; 214 | } 215 | }; 216 | 217 | const timeoutSignal = AbortSignal.timeout(timeoutMs); 218 | const onTimeout = async () => { 219 | respWait.reject(new Error("NIP-46: nostrconnect connection initiation flow timed out!")); 220 | }; 221 | timeoutSignal.addEventListener("abort", onTimeout, { once: true }); 222 | 223 | const unsub = relayPool.subscribe({ kinds: [24133], "#p": [localSigner.publicKey] }, onEvent); 224 | 225 | // cleanups to be performed on the settlement of `respWait`. 226 | const cleanup = () => { 227 | unsub(); 228 | timeoutSignal.removeEventListener("abort", onTimeout); 229 | }; 230 | return { connected: respWait.promise.finally(cleanup) }; 231 | } 232 | 233 | /** 234 | * Creates a NIP-46 remote signer handle with RPC response subscription started. 235 | * 236 | * It's guaranteed that the signer handle and its internal relay pool are disposed in case of an initialization error. 237 | */ 238 | public static async init( 239 | localSigner: NostrSigner, 240 | remotePubkey: string, 241 | relayPool: RelayPool, 242 | options: Nip46RpcClientOptions, 243 | ): Promise { 244 | const finalOpts = mergeOptionsWithDefaults(defaultRpcCliOptions, options); 245 | 246 | let rpcCli: Nip46RpcClient | undefined; 247 | try { 248 | rpcCli = new Nip46RpcClient(localSigner, remotePubkey, relayPool, finalOpts); 249 | await rpcCli.#startRespSubscription(); 250 | return rpcCli; 251 | } catch (e) { 252 | rpcCli?.dispose(); 253 | throw e; 254 | } 255 | } 256 | 257 | async #startRespSubscription() { 258 | const onevent = async (ev: NostrEvent) => { 259 | let rpcId: string | undefined; 260 | try { 261 | const resp = await parseNip46RpcResp(ev, this.#localSigner); 262 | rpcId = resp.id; 263 | 264 | const respWait = this.#inflightRpcs.get(resp.id); 265 | if (respWait === undefined) { 266 | console.debug("no waiter found for NIP-46 RPC response"); 267 | return; 268 | } 269 | 270 | switch (resp.status) { 271 | case "ok": 272 | respWait.resolve(resp.result); 273 | return; 274 | case "auth": 275 | this.#options.onAuthChallenge(resp.authUrl); 276 | return; 277 | case "error": 278 | respWait.reject(new Error(`NIP-46 RPC resulted in error: ${resp.error}`)); 279 | return; 280 | case "empty": 281 | respWait.reject(new Error("NIP-46 RPC: empty response")); 282 | return; 283 | } 284 | } catch (err) { 285 | console.error("error on receiving NIP-46 RPC response", err); 286 | } 287 | 288 | if (rpcId !== undefined) { 289 | this.#inflightRpcs.delete(rpcId); 290 | } 291 | }; 292 | 293 | const localPubkey = await this.#localSigner.getPublicKey(); 294 | this.#closeSub = this.#relayPool.subscribe({ kinds: [24133], "#p": [localPubkey] }, onevent); 295 | } 296 | 297 | #startWaitingRpcResp(rpcId: string): { 298 | waitResp: Promise; 299 | startCancelTimer: (timeoutMs: number) => void; 300 | } { 301 | const d = new Deferred(); 302 | this.#inflightRpcs.set(rpcId, d); 303 | 304 | const startCancelTimer = (timeoutMs: number) => { 305 | const signal = AbortSignal.timeout(timeoutMs); 306 | signal.addEventListener( 307 | "abort", 308 | () => { 309 | d.reject(new Error("NIP-46 RPC timed out!")); 310 | this.#inflightRpcs.delete(rpcId); 311 | }, 312 | { once: true }, 313 | ); 314 | }; 315 | 316 | return { waitResp: d.promise, startCancelTimer }; 317 | } 318 | 319 | public async request( 320 | method: M, 321 | params: Nip46RpcParams, 322 | timeoutMs = this.#options.requestTimeoutMs, 323 | ): Promise> { 324 | const rpcId = generateRandomString(); 325 | const { waitResp, startCancelTimer } = this.#startWaitingRpcResp(rpcId); 326 | 327 | const rpcReq: Nip46RpcReq = { 328 | id: rpcId, 329 | method, 330 | params: nip46RpcParamsEncoders[method](params), 331 | }; 332 | const cipheredReq = await this.#localSigner.nip44Encrypt(this.#remotePubkey, JSON.stringify(rpcReq)); 333 | const reqEv: NostrEventTemplate = { 334 | kind: 24133, 335 | tags: [["p", this.#remotePubkey]], 336 | content: cipheredReq, 337 | created_at: currentUnixtimeSec(), 338 | }; 339 | const signedReqEv = await this.#localSigner.signEvent(reqEv); 340 | 341 | await this.#relayPool.publish(signedReqEv); 342 | 343 | // once the request is sent, start a timer to cancel the request if it takes too long 344 | startCancelTimer(timeoutMs); 345 | 346 | // rethrow if RPC result in error. 347 | const rawResp = await waitResp; 348 | return nip46RpcResultDecoders[method](rawResp); 349 | } 350 | 351 | /** 352 | * Tries to reconnect to all the relays that are used to communicate with the remote signer. 353 | */ 354 | public reconnectToRelays() { 355 | this.#relayPool.reconnectAll(); 356 | } 357 | 358 | /** 359 | * Disposes this remote signer handle. 360 | */ 361 | public dispose() { 362 | if (this.#closeSub !== undefined) { 363 | this.#closeSub?.(); 364 | this.#closeSub = undefined; 365 | } 366 | this.#relayPool.dispose(); 367 | this.#inflightRpcs.clear(); 368 | } 369 | } 370 | -------------------------------------------------------------------------------- /src/nip46/signer.ts: -------------------------------------------------------------------------------- 1 | import type { Event as NostrEvent, EventTemplate as NostrEventTemplate } from "nostr-tools"; 2 | import { getPublicKey as getPubkeyFromHex } from "rx-nostr-crypto"; 3 | import { generateRandomString, mergeOptionsWithDefaults, parsePubkey } from "../helpers"; 4 | import type { NostrSigner, RelayList } from "../interface"; 5 | import { SecretKeySigner } from "../secret_key"; 6 | import { type RelayPool, RxNostrRelayPool } from "./relay_pool"; 7 | import { Nip46RpcClient, type Nip46RpcClientOptions, defaultRpcCliOptions } from "./rpc"; 8 | 9 | export type Nip46ConnectionParams = { 10 | remotePubkey: string; 11 | relayUrls: string[]; 12 | secretToken?: string | undefined; 13 | }; 14 | 15 | const BUNKER_TOKEN_PREFIX = "bunker://"; 16 | const BUNKER_TOKEN_PREFIX_LEN = BUNKER_TOKEN_PREFIX.length; 17 | 18 | // parse `bunker://` connection token (format: bunker://?relay=wss://...&relay=wss://...&secret=) 19 | export const parseBunkerToken = (token: string): Nip46ConnectionParams => { 20 | if (!token.startsWith(BUNKER_TOKEN_PREFIX)) { 21 | throw Error("invalid bunker connection token: must starts with 'bunker://'"); 22 | } 23 | const prefixTrimmed = token.substring(BUNKER_TOKEN_PREFIX_LEN); 24 | const qIdx = prefixTrimmed.indexOf("?"); 25 | if (qIdx < 0) { 26 | throw Error("invalid bunker connection token: must have '?'"); 27 | } 28 | 29 | const [rawPubkey, rawParams] = [prefixTrimmed.substring(0, qIdx), prefixTrimmed.substring(qIdx + 1)]; 30 | const remotePubkey = parsePubkey(rawPubkey); 31 | if (remotePubkey === undefined) { 32 | throw Error("invalid bunker connection token: malformed pubkey"); 33 | } 34 | 35 | const searchParams = new URLSearchParams(rawParams); 36 | const secretToken = searchParams.get("secret") ?? undefined; 37 | const relayUrls = searchParams.getAll("relay"); 38 | if (relayUrls.length === 0) { 39 | throw Error("invalid bunker connection token: must have at least 1 relay URL"); 40 | } 41 | 42 | return { 43 | remotePubkey, 44 | relayUrls, 45 | secretToken, 46 | }; 47 | }; 48 | 49 | export type Nip46SessionState = Nip46ConnectionParams & { 50 | sessionKey: string; 51 | }; 52 | 53 | type StartSessionResult = { 54 | /** 55 | * A handle for the connected NIP-46 remote signer. 56 | */ 57 | signer: Nip46RemoteSigner; 58 | 59 | /** 60 | * State data needed to resume a session to the NIP-46 remote signer later. 61 | */ 62 | session: Nip46SessionState; 63 | }; 64 | 65 | export type Nip46ClientMetadata = { 66 | name: string; 67 | url?: string; 68 | description?: string; 69 | icons?: string[]; 70 | }; 71 | 72 | export type Nip46RemoteSignerOptions = Nip46RpcClientOptions; 73 | 74 | export type Nip46RemoteSignerConnectOptions = Nip46RemoteSignerOptions & { 75 | /** 76 | * The maximum amount of time allowed to attempt to connect to a remote signer, in milliseconds. 77 | * 78 | * @default 30000 79 | */ 80 | connectTimeoutMs?: number; 81 | 82 | /** 83 | * The permissions to request on the connection. 84 | */ 85 | permissions?: string[]; 86 | }; 87 | 88 | const defaultConnectOptions: Required = { 89 | ...defaultRpcCliOptions, 90 | connectTimeoutMs: 30 * 1000, 91 | permissions: [], 92 | }; 93 | 94 | /** 95 | * An implementation of NostrSigner based on a [NIP-46](https://github.com/nostr-protocol/nips/blob/master/46.md) remote signer (a.k.a. Nostr Connect or nsecBunker). 96 | * It acts as a client-side handle for a NIP-46 remote signer. 97 | * 98 | * You can initialize a Nip46RemoteSigner instance by calling one of the following initialization methods, each corresponds to a signer discovery flow defined in NIP-46: 99 | * 100 | * - `Nip46RemoteSigner.connectToRemote(connToken)`: "Started by the signer (nsecBunker)" discovery flow 101 | * - `Nip46RemoteSigner.listenConnectionFromRemote(relayUrls, clientMetadata)`: "Started by the client" discovery flow 102 | * 103 | * During an initialization process, a session to a remote signer is established. Session state data is returned as a result of the initialization, along with a Nip46RemoteSigner instance. 104 | * Your app should store the session state data, and use it to resume the session later via `Nip46RemoteSigner.resumeSession(sessionState)`. 105 | */ 106 | export class Nip46RemoteSigner implements NostrSigner, Disposable { 107 | #rpcCli: Nip46RpcClient; 108 | #remotePubkey: string; 109 | 110 | private constructor(rpcCli: Nip46RpcClient, remotePubkey: string) { 111 | this.#rpcCli = rpcCli; 112 | this.#remotePubkey = remotePubkey; 113 | } 114 | 115 | /** 116 | * Creates a NIP-46 remote signer handle with RPC response subscription started. 117 | * 118 | * It's guaranteed that the signer handle and its internal relay pool are disposed in case of an initialization error. 119 | */ 120 | static async #init( 121 | localSigner: NostrSigner, 122 | remotePubkey: string, 123 | relayUrls: string[], 124 | options: Required, 125 | ): Promise { 126 | let relayPool: RelayPool | undefined; 127 | try { 128 | relayPool = new RxNostrRelayPool(relayUrls); 129 | } catch (e) { 130 | relayPool?.dispose(); 131 | throw e; 132 | } 133 | 134 | const rpcCli = await Nip46RpcClient.init(localSigner, remotePubkey, relayPool, options); 135 | return new Nip46RemoteSigner(rpcCli, remotePubkey); 136 | } 137 | 138 | /** 139 | * Initializes a NIP-46 remote signer handle, then performs a connection handshake. 140 | * 141 | * It's guaranteed that the signer handle and its internal relay pool are disposed in case of a connection handshake error. 142 | */ 143 | static async #connect( 144 | localSigner: NostrSigner, 145 | { remotePubkey, secretToken, relayUrls }: Nip46ConnectionParams, 146 | options: Required, 147 | ): Promise { 148 | const signer = await Nip46RemoteSigner.#init(localSigner, remotePubkey, relayUrls, options); 149 | 150 | // perform connection handshake 151 | try { 152 | const connParams: [string] = [remotePubkey]; 153 | if (secretToken !== undefined) { 154 | connParams.push(secretToken); 155 | } 156 | if (options.permissions.length > 0) { 157 | connParams.push(options.permissions.join(",")); 158 | } 159 | 160 | const connResp = await signer.#rpcCli.request("connect", connParams, options.connectTimeoutMs); 161 | if (connResp !== "ack") { 162 | console.warn("NIP-46 remote signer responded for `connect` with other than 'ack'"); 163 | } 164 | return signer; 165 | } catch (err) { 166 | // HACK: nsecBunker returns error if you connect twice to it with the same token. However, in spite of the error, other methods still work with the token. 167 | // It seems that Coracle just ignores this error on connect, and we follow the behavior here. 168 | if (err instanceof Error && err.message.includes("Token already redeemed")) { 169 | console.debug("ignoring 'Token already redeemed' error on connect from remote signer"); 170 | return signer; 171 | } 172 | 173 | signer.dispose(); 174 | throw err; 175 | } 176 | } 177 | 178 | /** 179 | * Connects to a NIP-46 remote signer with a given connection token, and establishes a session to the remote signer. 180 | * This is the "Started by the signer (nsecBunker)" signer discovery flow defined in NIP-46. 181 | * 182 | * Internally, it generates SecretKeySigner with a random secret key and use it to communicate with a remote signer. 183 | * This secret key acts as a "session key", and it is returned along with other session data (as `session` property). 184 | * You should store the session state data (`session`) in somewhere to resume the session later via `Nip46RemoteSigner.resumeSession`. 185 | * 186 | * @returns a Promise that resolves to an object that contains a handle for the connected remote signer and a session state 187 | */ 188 | public static async connectToRemote( 189 | connToken: string, 190 | options: Nip46RemoteSignerConnectOptions = {}, 191 | ): Promise { 192 | const finalOpts = mergeOptionsWithDefaults(defaultConnectOptions, options); 193 | 194 | const connParams = parseBunkerToken(connToken); 195 | const localSigner = SecretKeySigner.withRandomKey(); 196 | const sessionKey = localSigner.secretKey; 197 | 198 | return { 199 | signer: await Nip46RemoteSigner.#connect(localSigner, connParams, finalOpts), 200 | session: { 201 | sessionKey, 202 | ...connParams, 203 | }, 204 | }; 205 | } 206 | 207 | /** 208 | * Starts to listen a connection request from a NIP-46 remote signer, and once a connection request is received, establishes a session to the remote signer. 209 | * This is the "Started by the client" signer discovery flow defined in NIP-46. 210 | * 211 | * Internally, it generates SecretKeySigner with a random secret key and use it to communicate with a remote signer. 212 | * This secret key acts as a "session key", and it is returned along with other session data (as `session` property). 213 | * You should store the session state data (`session`) in somewhere to resume the session later via `Nip46RemoteSigner.resumeSession`. 214 | * 215 | * @returns an object with following properties: 216 | * - `connectUri`: a URI that can be shared with the remote signer to connect to this client 217 | * - `established`: a Promise that resolves to an object that contains a handle for the connected remote signer and a session state 218 | */ 219 | public static listenConnectionFromRemote( 220 | relayUrls: string[], 221 | clientMetadata: Nip46ClientMetadata, 222 | options: Nip46RemoteSignerConnectOptions = {}, 223 | ): { 224 | connectUri: string; 225 | established: Promise; 226 | } { 227 | const finalOpts = mergeOptionsWithDefaults(defaultConnectOptions, options); 228 | 229 | const localSigner = SecretKeySigner.withRandomKey(); 230 | const sessionKey = localSigner.secretKey; 231 | const localPubkey = getPubkeyFromHex(sessionKey); 232 | 233 | // construct nostrconnect URI 234 | const connSecret = generateRandomString(); 235 | const connUri = new URL(`nostrconnect://${localPubkey}`); 236 | for (const rurl of relayUrls) { 237 | connUri.searchParams.append("relay", rurl); 238 | } 239 | const metaWithPerms = { 240 | ...clientMetadata, 241 | perms: finalOpts.permissions.length > 0 ? finalOpts.permissions.join(",") : undefined, 242 | }; 243 | connUri.searchParams.set("metadata", JSON.stringify(metaWithPerms)); 244 | connUri.searchParams.set("secret", connSecret); 245 | 246 | // start listening for `connect` response from the remote signer 247 | const relayPool = new RxNostrRelayPool(relayUrls); 248 | 249 | const { connected } = Nip46RpcClient.startWaitingForConnectRespFromRemote( 250 | localSigner, 251 | relayPool, 252 | connSecret, 253 | finalOpts.connectTimeoutMs, 254 | ); 255 | 256 | // once `connect` response is received, initialize a handle for the remote signer 257 | const established = connected.then(async (remotePubkey): Promise => { 258 | const rpcCli = await Nip46RpcClient.init(localSigner, remotePubkey, relayPool, finalOpts); 259 | const signer = new Nip46RemoteSigner(rpcCli, remotePubkey); 260 | return { 261 | signer, 262 | session: { 263 | sessionKey, 264 | remotePubkey, 265 | relayUrls, 266 | }, 267 | }; 268 | }); 269 | 270 | return { 271 | connectUri: connUri.toString(), 272 | established, 273 | }; 274 | } 275 | 276 | /** 277 | * Resumes a session to a NIP-46 remote signer, which is established by `Nip46RemoteSigner.connectToRemote` or `Nip46RemoteSigner.listenConnectionFromRemote`. 278 | */ 279 | public static async resumeSession( 280 | { sessionKey, ...connParams }: Nip46SessionState, 281 | options: Nip46RemoteSignerOptions = {}, 282 | ): Promise { 283 | const finalOpts = mergeOptionsWithDefaults(defaultRpcCliOptions, options); 284 | 285 | const localSigner = new SecretKeySigner(sessionKey); 286 | return Nip46RemoteSigner.#init(localSigner, connParams.remotePubkey, connParams.relayUrls, finalOpts); 287 | } 288 | 289 | /** 290 | * Returns the public key that corresponds to the underlying secret key, in hex string format. 291 | */ 292 | public async getPublicKey(): Promise { 293 | return this.#rpcCli.request("get_public_key", []); 294 | } 295 | 296 | /** 297 | * Returns the list of relays preferred by the user. 298 | * 299 | * Each entry is a mapping from the relay URL to the preferred use (read/write) of the relay. 300 | */ 301 | public async getRelays(): Promise { 302 | return this.#rpcCli.request("get_relays", []); 303 | } 304 | 305 | /** 306 | * Signs a given Nostr event with the underlying secret key. 307 | * 308 | * @param event a Nostr event template (unsigned event) 309 | * @returns a Promise that resolves to a signed Nostr event 310 | */ 311 | public async signEvent(event: NostrEventTemplate): Promise { 312 | // HACK: set pubkey to event here, since sometimes nsecbunkerd fails to sign event if the input doesn't have pubkey field. 313 | const ev = event as NostrEventTemplate & { pubkey?: string }; 314 | if (!ev.pubkey) { 315 | ev.pubkey = this.#remotePubkey; 316 | } 317 | return this.#rpcCli.request("sign_event", [ev]); 318 | } 319 | 320 | /** 321 | * Encrypts a given text to secretly communicate with others, by the encryption algorithm defined in [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md). 322 | * 323 | * @param recipientPubkey a public key of a message recipient, in hex string format 324 | * @param plaintext a plaintext to encrypt 325 | * @returns a Promise that resolves to a encrypted text 326 | */ 327 | public async nip04Encrypt(recipientPubkey: string, plaintext: string): Promise { 328 | return this.#rpcCli.request("nip04_encrypt", [recipientPubkey, plaintext]); 329 | } 330 | 331 | /** 332 | * Decrypts a given ciphertext from others, by the decryption algorithm defined in [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md). 333 | * 334 | * @param senderPubkey a public key of a message sender, in hex string format 335 | * @param ciphertext a ciphertext to decrypt 336 | * @returns a Promise that resolves to a decrypted text 337 | */ 338 | public async nip04Decrypt(senderPubkey: string, ciphertext: string): Promise { 339 | return this.#rpcCli.request("nip04_decrypt", [senderPubkey, ciphertext]); 340 | } 341 | 342 | /** 343 | * Encrypts a given text to secretly communicate with others, by the encryption algorithm defined in [NIP-44](https://github.com/nostr-protocol/nips/blob/master/44.md). 344 | * 345 | * @param recipientPubkey a public key of a message recipient, in hex string format 346 | * @param plaintext a plaintext to encrypt 347 | * @returns a Promise that resolves to a encrypted text 348 | */ 349 | public async nip44Encrypt(recipientPubkey: string, plaintext: string): Promise { 350 | return this.#rpcCli.request("nip44_encrypt", [recipientPubkey, plaintext]); 351 | } 352 | 353 | /** 354 | * Decrypts a given ciphertext from others, by the decryption algorithm defined in [NIP-44](https://github.com/nostr-protocol/nips/blob/master/44.md). 355 | * 356 | * @param senderPubkey a public key of a message sender, in hex string format 357 | * @param ciphertext a ciphertext to decrypt 358 | * @returns a Promise that resolves to a decrypted text 359 | */ 360 | public async nip44Decrypt(senderPubkey: string, ciphertext: string): Promise { 361 | return this.#rpcCli.request("nip44_decrypt", [senderPubkey, ciphertext]); 362 | } 363 | 364 | /** 365 | * Sends a ping to the remote signer. 366 | */ 367 | public async ping(): Promise { 368 | const resp = await this.#rpcCli.request("ping", []); 369 | // response should be "pong" 370 | if (resp !== "pong") { 371 | throw Error("unexpected response for ping from the remote signer"); 372 | } 373 | } 374 | 375 | /** 376 | * Tries to reconnect to all the relays that are used to communicate with the remote signer. 377 | */ 378 | public reconnectToRpcRelays() { 379 | this.#rpcCli.reconnectToRelays(); 380 | } 381 | 382 | /** 383 | * Disposes this remote signer handle. 384 | */ 385 | public dispose() { 386 | this.#rpcCli.dispose(); 387 | } 388 | 389 | /** 390 | * Disposes this remote signer handle. 391 | */ 392 | public [Symbol.dispose]() { 393 | this.dispose(); 394 | } 395 | } 396 | --------------------------------------------------------------------------------