mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
Merge pull request #1737 from PR0M3TH3AN/ai/doc-nip46-client-20241026-1908093676591025314
chore(ai): document js/nostr/nip46Client.js
This commit is contained in:
2 files changed
+196
No files matched your search
@@ -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.
|
||||
@@ -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.");
|
||||
|
||||
Reference in new issue
Block a user