Files
client/plans/nsec-bunker-page.md
2026-04-17 16:52:51 -04:00

6.8 KiB

NSec Bunker Page — Architecture & Implementation Plan

Overview

Transform www/bunker.html (currently a copy of template.html) into a NIP-46 remote signer (bunker) page. The user logs in normally via nostr-login-lite, and the page uses their nsec to run a bunker backend that signs events on behalf of remote client applications.

Protocol: NIP-46 Remote Signing

NIP-46 defines a protocol where a signer (bunker) holds the private key and a client (any nostr app) sends signing requests over nostr relays using kind 24133 events encrypted with NIP-44.

Connection Flow: bunker://

sequenceDiagram
    participant U as User
    participant B as Bunker Page
    participant R as Relay
    participant C as Client App

    U->>B: Logs in via nostr-login-lite
    B->>B: Extracts nsec from auth state
    B->>B: Creates NDKPrivateKeySigner
    B->>B: Instantiates NDKNip46Backend
    B->>B: Generates bunker:// URI
    B->>U: Displays URI + QR code
    U-->>C: Copies bunker:// URI to client app
    B->>R: Subscribes to kind 24133 tagged to pubkey
    C->>R: Sends connect request with secret
    R->>B: Delivers connect event
    B->>B: Validates secret, approves
    B->>R: Sends ack response
    Note over C,B: Connection established
    C->>R: sign_event request
    R->>B: Delivers request
    B->>B: Signs with nsec
    B->>R: Sends signed event response
    R->>C: Delivers signed event

Architecture

Key Decision: NDKNip46Backend over nostr-tools

Why NDKNip46Backend:

  • Complete bunker server implementation already in ndk-core.bundle.js
  • Handles all NIP-46 methods: connect, sign_event, get_public_key, ping, nip04_encrypt/decrypt, nip44_encrypt/decrypt
  • Strategy pattern for handlers — extensible
  • permitCallback for authorization decisions
  • Uses NDK relay pool management
  • Available via window.NDK.NDKNip46Backend

Why not nostr-tools:

  • nostr-tools nip46 module only has the client sideBunkerSigner connects to a bunker
  • Building a bunker server from scratch with nostr-tools would require reimplementing the entire NIP-46 RPC protocol

Dual NDK Architecture

The page uses two separate NDK contexts:

  1. SharedWorker NDK (existing) — handles normal page operations via init-ndk.mjs, relay UI, blossom, etc.
  2. Standalone NDK instance (new) — created directly in the page for NDKNip46Backend. This is necessary because the backend needs direct access to subscribe/publish, not through the worker message bridge.
flowchart TB
    subgraph Page[bunker.html]
        Auth[nostr-login-lite auth]
        Worker[SharedWorker NDK - page ops]
        BunkerNDK[Standalone NDK - bunker backend]
        Backend[NDKNip46Backend]
        Signer[NDKPrivateKeySigner - nsec]
        UI[Bunker UI - URI, QR, log]
    end

    Auth -->|nsec| Signer
    Auth -->|pubkey| Worker
    Signer --> Backend
    BunkerNDK --> Backend
    Backend -->|kind 24133| Relays[Nostr Relays]
    Relays -->|sign requests| Backend
    Backend -->|signed responses| Relays
    Worker -->|relay list| BunkerNDK

Implementation Details

1. Script Loading

Add ndk-core.bundle.js to the page before nostr-lite.js:

<script src="./nostr.bundle.js"></script>
<script src="./ndk-core.bundle.js"></script>
<script src="./nostr-lite.js"></script>

This makes window.NDK available with all exports including:

  • NDK (default) — the main NDK class
  • NDKNip46Backend — the bunker backend
  • NDKPrivateKeySigner — signer from nsec
  • NDKNostrRpc — RPC layer

2. Extracting the nsec

After authentication via nostr-login-lite, the secret key is stored in localStorage under key nostr_login_lite_auth as authState.secret in nsec or hex format.

function getSecretKey() {
    const stored = localStorage.getItem('nostr_login_lite_auth');
    if (!stored) return null;
    const authState = JSON.parse(stored);
    if (authState.method !== 'local' || !authState.secret) return null;
    return authState.secret; // nsec1... or hex string
}

Important: The bunker only works when logged in via the "local" method (nsec/hex key). Extension and NIP-46 auth methods don't expose the secret key.

3. Standalone NDK Instance

const { default: NDKClass, NDKPrivateKeySigner, NDKNip46Backend } = window.NDK;

// Get relay URLs from the SharedWorker NDK
const relayData = await getRelayData();
const relayUrls = relayData.relays.map(r => r.url);

// Create standalone NDK for the bunker
const bunkerNdk = new NDKClass({
    explicitRelayUrls: relayUrls
});
await bunkerNdk.connect();

4. NDKNip46Backend Setup

const signer = new NDKPrivateKeySigner(secretKey);
const backend = new NDKNip46Backend(bunkerNdk, signer, async (params) => {
    // Auto-approve all requests, log them to UI
    logRequest(params);
    return true;
});
await backend.start();

5. Generating the bunker:// URI

const user = await signer.user();
const secret = crypto.getRandomValues(new Uint8Array(32));
const secretHex = Array.from(secret).map(b => b.toString(16).padStart(2, '0')).join('');

const bunkerUrl = new URL(`bunker://${user.pubkey}`);
relayUrls.forEach(r => bunkerUrl.searchParams.append('relay', r));
bunkerUrl.searchParams.set('secret', secretHex);

const bunkerUri = bunkerUrl.toString();

6. UI Layout

The divBody section will contain:

  • Status indicator — bunker running/stopped, connected clients count
  • bunker:// URI display — copyable text field
  • QR code — scannable QR of the bunker:// URI (using existing qrcode-generator.min.js)
  • Request log — scrollable list showing incoming requests with timestamp, method, remote pubkey, and result
  • Start/Stop button — toggle the bunker on/off

7. Lifecycle Management

  • Page load: Auth → extract nsec → start bunker → display URI
  • Page close/navigate away: beforeunload event to clean up subscriptions
  • Stop button: Tear down backend, clear URI display
  • Auth requirement: Must be logged in via "local" method. Show warning if using extension/NIP-46 auth since nsec is not available.

Security Considerations

  • The nsec is already in localStorage (nostr-login-lite stores it in plaintext for the "local" method). The bunker page does not introduce new storage of the key.
  • The bunker:// secret is a one-time random value generated per session.
  • Auto-approve means any client with the bunker:// URI can sign events. The user controls access by sharing the URI selectively.
  • The page should warn users that the bunker is active and signing requests while the tab is open.

Files Modified

File Changes
www/bunker.html Full page implementation: add ndk-core.bundle.js, bunker UI, backend logic

No new JS modules needed — all logic fits in the page's inline script, using window.NDK and window.NostrTools.