From 9d56dc7d0b20fe51a679be4f8573a3c068c9f33a Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:34:58 +0000 Subject: [PATCH] chore(ai): doc js/nostr/nip46Client.js (agent) --- docs/nip46-client-overview.md | 89 ++++++++++++++++++++++++++++ js/nostr/nip46Client.js | 107 ++++++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 docs/nip46-client-overview.md diff --git a/docs/nip46-client-overview.md b/docs/nip46-client-overview.md new file mode 100644 index 00000000..2db7f862 --- /dev/null +++ b/docs/nip46-client-overview.md @@ -0,0 +1,89 @@ +# NIP-46 Client Overview + +`js/nostr/nip46Client.js` implements the **Nostr Connect** (NIP-46) protocol, allowing the application to delegate event signing to a remote signer (e.g., a "Bunker" or mobile wallet) instead of managing private keys locally. + +## Architecture + +The communication happens over standard Nostr relays using **Kind 24133** (Nostr Connect) events. These events act as an RPC (Remote Procedure Call) transport layer. + +### Components + +1. **`Nip46RpcClient`**: The central controller. It manages: + * **Encryption**: Secures payloads using NIP-44 (XChaCha20) or NIP-04 (legacy AES). + * **Subscription**: Listens for responses from the specific remote signer pubkey. + * **Queueing**: Uses `Nip46RequestQueue` to serialize and throttle requests (preventing "rate-limited" errors). + * **Session**: Persists connection details (remote pubkey, relays) to `localStorage`. + +2. **`Nip46RequestQueue`**: A priority queue ensuring RPC calls are processed sequentially and respect relay rate limits. + +### Connection Flow + +1. **Parsing**: The app receives a connection URI (e.g., `bunker://...` or `nostrconnect://...`). +2. **Handshake**: `Nip46RpcClient` initiates a `connect` RPC call. +3. **Verification**: If a `secret` was provided in the URI, the client verifies the signer's response matches it. +4. **Metadata**: The client fetches the user's public key via `get_public_key`. + +```mermaid +sequenceDiagram + participant App + participant Client as Nip46RpcClient + participant Relay + participant Signer as Remote Signer + + App->>Client: connect(bunkerURI) + Client->>Client: parse URI & derive ephemeral keys + Client->>Relay: Publish Kind 24133 (method: "connect") + Relay->>Signer: Deliver Event + Signer->>Relay: Publish Kind 24133 (result: "ack") + Relay->>Client: Deliver Response + Client->>Client: Verify Secret & Permissions + Client-->>App: Connected +``` + +## Signing Flow + +When the app needs to publish a video or update a profile, it calls `signEvent`. + +```mermaid +sequenceDiagram + participant App + participant Client as Nip46RpcClient + participant Relay + participant Signer + + App->>Client: signEvent(unsignedEvent) + Client->>Client: JSON.stringify(unsignedEvent) + Client->>Client: Encrypt Payload (NIP-44) + Client->>Relay: Publish Kind 24133 (method: "sign_event") + + Note over Client, Signer: Network Latency / Queueing + + Signer->>Relay: Publish Kind 24133 (result: signedJson) + Relay->>Client: Deliver Response + Client->>Client: Decrypt & Parse + Client-->>App: Signed Event +``` + +## Security & Encryption + +* **Ephemeral Keys**: The client generates a random local key pair for each session. The user's main private key is **never** transmitted. +* **Encryption Negotiation**: The client prefers **NIP-44 v2** but falls back to NIP-04 if the signer doesn't support it. +* **Auth Challenges**: If the signer requires out-of-band authentication (e.g., scanning a QR code), it returns an `auth_url` error, which the client bubbles up to the UI. + +## Key Functions + +* `parseNip46ConnectionString(uri)`: specific parser for `bunker://` and `nostrconnect://` URIs, extracting secrets, relays, and remote pubkeys. +* `createNip46Cipher(...)`: Factory that creates an encryption interface (encrypt/decrypt) based on available libraries and negotiated algorithms. +* `decryptNip46PayloadWithKeys(...)`: Helper to brute-force decrypt an incoming message using all available algorithms (handling legacy vs modern formats). + +## Error Handling + +* **Timeouts**: RPC calls have a default timeout (15s). +* **Rate Limiting**: If relays reply with `rate-limited`, the client performs exponential backoff. +* **Session Recovery**: On page load, `readStoredNip46Session` rehydrates the session from `localStorage` to resume without re-connecting. + +## When to change + +* **Protocol Updates**: If NIP-46 or NIP-44 standards are updated (e.g. new encryption vectors, new RPC methods). +* **Security Audit**: If a security review identifies weaknesses in the ephemeral key handling or encryption flow. +* **Relay Logic**: If the strategy for relay selection or rate-limiting needs to be optimized for better connectivity. diff --git a/js/nostr/nip46Client.js b/js/nostr/nip46Client.js index 45d1a78b..da574157 100644 --- a/js/nostr/nip46Client.js +++ b/js/nostr/nip46Client.js @@ -1,5 +1,18 @@ // js/nostr/nip46Client.js +/** + * NIP-46 (Nostr Connect) Client Implementation. + * + * This module provides a client for the Nostr Connect protocol, allowing the application + * to delegate event signing to a remote signer (e.g., a "Bunker" or mobile wallet). + * + * Key components: + * - `Nip46RpcClient`: The main class managing the RPC session. + * - `parseNip46ConnectionString`: URI parser for `bunker://` and `nostrconnect://`. + * + * For a detailed architecture overview, see `docs/nip46-client-overview.md`. + */ + import { isDevMode } from "../config.js"; import { DEFAULT_RELAY_URLS, @@ -30,6 +43,10 @@ export const NIP46_ENCRYPTION_ALGORITHMS = Object.freeze([ "nip04", ]); +/** + * resolves the appropriate storage mechanism (localStorage or polyfill) for NIP-46 sessions. + * @returns {Storage|null} The storage interface or null if unavailable. + */ export function getNip46Storage() { if (typeof localStorage !== "undefined" && localStorage) { return localStorage; @@ -42,6 +59,13 @@ export function getNip46Storage() { return null; } +/** + * Cleans and normalizes a list of relay URLs. + * Removes duplicates, trailing slashes, and invalid URLs. + * + * @param {string[]} list - The raw list of relay URLs. + * @returns {string[]} The sanitized list of relay URLs. + */ export function sanitizeRelayList(list) { const seen = new Set(); const sanitized = []; @@ -86,6 +110,13 @@ export function sanitizeRelayList(list) { return sanitized; } +/** + * Validates and normalizes a NIP-46 session object from storage. + * Ensures strict schema compliance to prevent prototype pollution or corrupted state. + * + * @param {object} candidate - The raw session object from storage. + * @returns {object|null} The valid session object or null if invalid. + */ export function sanitizeStoredNip46Session(candidate) { if (!candidate || typeof candidate !== "object") { return null; @@ -484,6 +515,13 @@ export function normalizeNostrPubkey(candidate) { return trimmed.toLowerCase(); } +/** + * Parses a NIP-46 connection URI (bunker:// or nostrconnect://) into a structured configuration. + * Handles decoding of standard params (relay, secret, perms) and bunker-specific logic (user hints). + * + * @param {string} uri - The connection URI. + * @returns {object|null} The parsed configuration object or null if invalid. + */ export function parseNip46ConnectionString(uri) { const value = typeof uri === "string" ? uri.trim() : ""; if (!value) { @@ -1066,6 +1104,17 @@ function resolveAvailableNip46Ciphers( return available; } +/** + * Creates an encryption interface (encrypt/decrypt) for NIP-46 communication. + * Automatically selects the best available algorithm (NIP-44 v2 preferred) supported by the environment. + * + * @param {object} tools - The nostr-tools library instance. + * @param {string} privateKey - The local client's private key (hex). + * @param {string} remotePubkey - The remote signer's public key (hex). + * @param {object} [options] - Configuration options. + * @param {string} [options.preferredAlgorithm] - Force a specific algorithm ("nip44.v2", "nip04"). + * @returns {object} An object with `encrypt(text)` and `decrypt(text)` methods. + */ export function createNip46Cipher(tools, privateKey, remotePubkey, options = {}) { const available = resolveAvailableNip46Ciphers(tools, privateKey, remotePubkey, options); @@ -1076,6 +1125,15 @@ export function createNip46Cipher(tools, privateKey, remotePubkey, options = {}) return available[0]; } +/** + * Attempts to decrypt a NIP-46 payload using all available encryption algorithms. + * Useful when the specific algorithm used by the remote signer is unknown (e.g., during handshake). + * + * @param {string} privateKey - The local client's private key. + * @param {string} remotePubkey - The remote signer's public key. + * @param {string} ciphertext - The encrypted payload. + * @returns {Promise} The decrypted result containing `{ plaintext, algorithm }`. + */ export async function decryptNip46PayloadWithKeys(privateKey, remotePubkey, ciphertext) { const tools = (await ensureNostrTools()) || getCachedNostrTools(); if (!tools) { @@ -1333,7 +1391,28 @@ export async function attemptDecryptNip46HandshakePayload({ }); throw failure; } + +/** + * Manages a NIP-46 (Nostr Connect) remote signing session. + * Handles the RPC loop: Encrypt Request -> Publish Event -> Wait for Response -> Decrypt. + * + * It uses a "Stale-While-Revalidate" approach for subscriptions and maintains a + * priority queue for requests to avoid rate-limiting issues with relays. + */ export class Nip46RpcClient { + /** + * @param {object} config + * @param {NostrClient} [config.nostrClient] - The main app client (for pool/relays). + * @param {string} config.clientPrivateKey - The ephemeral local private key (hex). + * @param {string} [config.clientPublicKey] - The ephemeral local public key (hex). + * @param {string} config.remotePubkey - The remote signer's public key (hex). + * @param {string[]} [config.relays] - List of relay URLs to use for the RPC channel. + * @param {string} [config.encryption] - Preferred encryption algorithm ("nip44.v2" or "nip04"). + * @param {string} [config.secret] - Optional shared secret (for initial connection verification). + * @param {string} [config.permissions] - Requested permissions (comma-separated). + * @param {object} [config.metadata] - Metadata to identify this app to the signer. + * @param {function} [config.signEvent] - Function to sign the RPC request events. + */ constructor({ nostrClient, clientPrivateKey, @@ -1647,6 +1726,18 @@ export class Nip46RpcClient { this.pendingRequests.clear(); } + /** + * Sends an RPC command to the remote signer and awaits the response. + * Handles encryption, signing, publishing, and timeouts. + * + * @param {string} method - The RPC method name (e.g., "connect", "sign_event"). + * @param {Array} [params=[]] - The parameters for the method. + * @param {object} [options={}] - Options for this specific call. + * @param {number} [options.timeoutMs] - Timeout in milliseconds. + * @param {number} [options.priority] - Priority level (High/Normal/Low). + * @param {number} [options.retries] - Number of retry attempts. + * @returns {Promise} The result from the remote signer. + */ async sendRpc(method, params = [], options = {}) { if (this.destroyed) { throw new Error("Remote signer session has been disposed."); @@ -1855,6 +1946,14 @@ export class Nip46RpcClient { }, priority); } + /** + * Initiates the connection handshake ("connect" method). + * Verifies that the remote signer acknowledges the shared secret (if one exists). + * + * @param {object} [opts] + * @param {string} [opts.permissions] - Override permissions to request. + * @returns {Promise} The response result (usually "ack"). + */ async connect({ permissions } = {}) { const params = [this.remotePubkey]; const requestedPermissions = permissions || this.permissions || ""; @@ -1935,6 +2034,14 @@ export class Nip46RpcClient { } } + /** + * Requests the remote signer to sign a Nostr event. + * + * @param {object} event - The raw event object (unsigned). + * @param {object} [options] + * @param {number} [options.timeoutMs] - Custom timeout. + * @returns {Promise} The fully signed event object. + */ async signEvent(event, options = {}) { if (!event || typeof event !== "object") { throw new Error("A Nostr event is required for remote signing.");