Merge pull request #1737 from PR0M3TH3AN/ai/doc-nip46-client-20241026-1908093676591025314

chore(ai): document js/nostr/nip46Client.js
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-01-26 10:37:09 -05:00
commit c650597da1
2 files changed
+196

No files matched your search

+89
View File
@@ -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.
+107
View File
@@ -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<object>} 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<any>} 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<string>} 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<object>} 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.");