Skip to content

77elements/noorsigner

Repository files navigation

NoorSigner - Secure Key Signer for Nostr

Key signer for macOS and Linux.

NoorSigner keeps your Nostr private keys safe. It runs in the background and signs messages for your Nostr apps - your keys never leave your computer.

Works standalone with any Nostr client that supports external signers. Battle-tested with NoorNote.


User Guide

First Time Setup

Step 1: Add your first account

./noorsigner add-account

You'll be asked for:

  1. Your nsec (private key) - input is hidden for security
  2. A password (8+ characters) - used to encrypt your key

That's it! Your key is now safely stored.

Step 2: Start the daemon

./noorsigner daemon

Enter your password when asked. The daemon will run in the background - you can close the terminal.

Your Nostr app can now use NoorSigner for signing!


Adding More Accounts

Want to use multiple Nostr identities? Just add more accounts:

./noorsigner add-account

Each account has its own password.


Switching Between Accounts

See all your accounts:

./noorsigner list-accounts

Output shows which account is active (*):

Stored accounts:

* npub1abc...  (active)
  npub1def...

Total: 2 account(s)

Switch to a different account:

./noorsigner switch npub1def...

Enter the password for that account. If the daemon is running, restart it to use the new account.


Removing an Account

./noorsigner remove-account npub1def...

You'll need to enter the account's password to confirm.


Daily Usage

Once set up, just start the daemon:

./noorsigner daemon
  • If you used NoorSigner in the last 24 hours: No password needed!
  • After 24 hours or a reboot: Enter your password once

The daemon stays running in the background. Your Nostr app handles the rest.


Commands Overview

Command What it does
add-account Add a new Nostr account
add-account --stdin Add account non-interactively (JSON via stdin)
list-accounts Show all accounts
switch <npub> Switch to another account
remove-account <npub> Delete an account
daemon Start the background signer (prompts for password)
daemon --password-stdin Start daemon with password piped via stdin


Technical Documentation

The following sections are for developers and advanced users.


Features

  • 🔐 Secure Key Storage: NIP-49 compatible scrypt encryption
  • 👥 Multi-Account Support: Manage multiple Nostr identities
  • 🛡️ Trust Mode: 24-hour authentication caching per account
  • 🔑 NIP-44 & NIP-04: Encryption/decryption for DMs
  • 🔌 Unix Socket IPC: Fast, secure local communication
  • 🔒 Memory Safety: Keys cleared from memory after use
  • 🔄 Background Daemon: Fork-based process isolation
  • 🚀 Live Account Switching: Switch accounts without restarting daemon

Installation

Pre-built Binaries

Download the latest binary for your platform from GitHub Releases.

Platform Binary
macOS (ARM64) noorsigner-darwin-arm64
macOS (x86_64) noorsigner-darwin-amd64
Linux (x86_64) noorsigner-linux-amd64
Linux (ARM64) noorsigner-linux-arm64

Recommended Binary Location

Place the binary where your application can find it. Common conventions:

Context Path
System-wide /usr/local/bin/noorsigner
Per-user ~/.local/bin/noorsigner
Bundled with your app <your-app>/resources/noorsigner

Make sure the binary is executable: chmod +x noorsigner

Build from Source

git clone https://github.com/77elements/noorsigner.git
cd noorsigner
go build -o noorsigner .

See Building from Source for multi-platform builds.


Quick Start (Developer)

1. Add First Account

./noorsigner add-account

This will:

  • Prompt for your nsec (private key, hidden input)
  • Ask for an encryption password (8+ characters)
  • Save encrypted key to ~/.noorsigner/accounts/<npub>/keys.encrypted
  • Set this as the active account

Note: noorsigner init is an alias for add-account when no accounts exist.

2. Start Daemon

./noorsigner daemon

This will:

  • Prompt for your encryption password (if Trust Mode expired)
  • Create a Trust Mode session (24 hours)
  • Fork to background
  • Create Unix socket at ~/.noorsigner/noorsigner.sock

3. Connect from Client

Your Nostr client can now communicate with the daemon via the Unix socket.


CLI Commands

Account Management

# Add a new account (interactive)
noorsigner add-account

# Add account non-interactively (for integration with other apps)
echo '{"nsec":"nsec1...","password":"your-password"}' | noorsigner add-account --stdin

# List all accounts (* = active)
noorsigner list-accounts

# Switch to a different account
noorsigner switch <npub>

# Remove an account (requires password confirmation)
noorsigner remove-account <npub>

# Initialize (alias for add-account, first account only)
noorsigner init

Non-Interactive Mode (--stdin)

The --stdin flag allows programmatic account creation without user interaction. This is useful for:

  • Onboarding flows in GUI applications
  • Automated setup scripts
  • Integration with other Nostr clients

Input Format (JSON via stdin):

{
  "nsec": "nsec1...",
  "password": "minimum-8-characters"
}

Output (JSON):

{
  "success": true,
  "npub": "npub1...",
  "pubkey": "hex-pubkey"
}

Features:

  • Creates encrypted key storage
  • Sets account as active
  • Creates Trust Mode session (daemon can start without password prompt)

Example (Bash):

echo '{"nsec":"nsec1abc...","password":"mypassword123"}' | noorsigner add-account --stdin

Example (from another application):

const { spawn } = require('child_process');

const child = spawn('noorsigner', ['add-account', '--stdin']);
child.stdin.write(JSON.stringify({ nsec: 'nsec1...', password: 'mypassword' }));
child.stdin.end();

child.stdout.on('data', (data) => {
  const result = JSON.parse(data.toString());
  if (result.success) {
    console.log('Account created:', result.npub);
  }
});

Daemon

# Start the signing daemon (interactive password prompt)
noorsigner daemon

# Start daemon with password piped via stdin (for GUI integration)
echo "mypassword" | noorsigner daemon --password-stdin

The --password-stdin flag reads the password from stdin instead of prompting in a terminal. This enables GUI applications to start the daemon without opening a terminal window. The password is read as a single line (newline-terminated).

Example (from another application):

const child = spawn('noorsigner', ['daemon', '--password-stdin']);
child.stdin.write(password + '\n');
child.stdin.end();
// Daemon forks to background on success, parent process exits

Testing & Debugging

# Sign event with stored key (requires password)
noorsigner sign

# Test signing via daemon
noorsigner test-daemon

# Test signing with direct nsec input
noorsigner test <nsec>

Multi-Account System

File Structure

~/.noorsigner/
├── accounts/
│   ├── npub1abc.../
│   │   ├── keys.encrypted    # Encrypted nsec
│   │   └── trust_session     # 24h password cache
│   └── npub1def.../
│       ├── keys.encrypted
│       └── trust_session
├── active_account            # Currently active npub
└── noorsigner.sock           # Daemon socket (shared)

How It Works

  1. Each account has its own directory under accounts/
  2. Each account has separate encryption password
  3. Each account has its own Trust Mode session
  4. One daemon instance serves all accounts
  5. Live account switching via API (password required)

Migration from Single-Account

When upgrading from an older single-account NoorSigner:

  • Run any command (e.g., noorsigner daemon)
  • Enter your password when prompted
  • Old key is migrated to new structure automatically
  • Old files are removed after successful migration

API Documentation

Protocol

Transport: Unix Domain Socket (JSON newline-delimited)

Socket Path: ~/.noorsigner/noorsigner.sock

Request Format:

{
  "id": "unique-request-id",
  "method": "method_name",
  ...additional fields per method
}

Response Formats

The API uses two response formats depending on the method category:

Core methods (get_npub, get_pubkey, sign_event, encryption, daemon control):

{
  "id": "req-001",
  "signature": "result-value"
}

On error: {"id": "req-001", "error": "error message"}

Account management methods (add_account, switch_account, remove_account, list_accounts, get_active_account):

{
  "id": "req-001",
  "success": true,
  "pubkey": "...",
  "npub": "..."
}

On error: {"id": "req-001", "success": false, "error": "error message"}


Core Methods

get_pubkey

Get the hex public key of the currently active account.

Request:

{
  "id": "req-001",
  "method": "get_pubkey"
}

Response:

{
  "id": "req-001",
  "signature": "abc123def456..."
}

get_npub

Get the bech32-encoded public key (npub) of the currently active account.

Request:

{
  "id": "req-001",
  "method": "get_npub"
}

Response:

{
  "id": "req-001",
  "signature": "npub1..."
}

sign_event

Sign a Nostr event (NIP-01).

Request:

{
  "id": "req-002",
  "method": "sign_event",
  "event_json": "{\"content\":\"Hello\",\"kind\":1,\"tags\":[],\"created_at\":1234567890}"
}

Response:

{
  "id": "req-002",
  "signature": "hex-schnorr-signature"
}

Encryption Methods

nip44_encrypt

Encrypt plaintext using NIP-44 (modern encryption).

Request:

{
  "id": "req-003",
  "method": "nip44_encrypt",
  "plaintext": "Secret message",
  "recipient_pubkey": "hex-pubkey-of-recipient"
}

Response:

{
  "id": "req-003",
  "signature": "encrypted-payload"
}

nip44_decrypt

Decrypt NIP-44 encrypted payload.

Request:

{
  "id": "req-004",
  "method": "nip44_decrypt",
  "payload": "encrypted-payload",
  "sender_pubkey": "hex-pubkey-of-sender"
}

Response:

{
  "id": "req-004",
  "signature": "Decrypted message"
}

nip04_encrypt

Encrypt plaintext using NIP-04 (deprecated but widely compatible).

Request:

{
  "id": "req-005",
  "method": "nip04_encrypt",
  "plaintext": "Secret message",
  "recipient_pubkey": "hex-pubkey-of-recipient"
}

Response:

{
  "id": "req-005",
  "signature": "encrypted-payload"
}

nip04_decrypt

Decrypt NIP-04 encrypted payload.

Request:

{
  "id": "req-006",
  "method": "nip04_decrypt",
  "payload": "encrypted-payload",
  "sender_pubkey": "hex-pubkey-of-sender"
}

Response:

{
  "id": "req-006",
  "signature": "Decrypted message"
}

Multi-Account Methods

list_accounts

List all stored accounts with their metadata.

Request:

{
  "id": "req-010",
  "method": "list_accounts"
}

Response:

{
  "id": "req-010",
  "accounts": [
    {
      "pubkey": "abc123...",
      "npub": "npub1abc...",
      "created_at": 1234567890
    },
    {
      "pubkey": "def456...",
      "npub": "npub1def...",
      "created_at": 1234567891
    }
  ],
  "active_pubkey": "abc123..."
}

add_account

Add a new account to the daemon.

Important: set_active only updates the active_account file on disk. It does not switch the daemon's in-memory key. To actually sign with the new account, call switch_account afterwards (see GUI Integration Guide).

Request:

{
  "id": "req-011",
  "method": "add_account",
  "nsec": "nsec1...",
  "password": "encryption-password",
  "set_active": true
}

Response:

{
  "id": "req-011",
  "success": true,
  "pubkey": "abc123...",
  "npub": "npub1abc..."
}

Error Response:

{
  "id": "req-011",
  "success": false,
  "error": "account already exists"
}

switch_account

Switch to a different account (loads new key into memory).

Request (by pubkey):

{
  "id": "req-012",
  "method": "switch_account",
  "pubkey": "def456...",
  "password": "password-for-target-account"
}

Request (by npub):

{
  "id": "req-012",
  "method": "switch_account",
  "npub": "npub1def...",
  "password": "password-for-target-account"
}

Response:

{
  "id": "req-012",
  "success": true,
  "pubkey": "def456...",
  "npub": "npub1def..."
}

remove_account

Remove an account from storage.

Request:

{
  "id": "req-013",
  "method": "remove_account",
  "pubkey": "def456...",
  "password": "password-for-this-account"
}

Response:

{
  "id": "req-013",
  "success": true
}

Error (cannot remove active account):

{
  "id": "req-013",
  "error": "cannot remove active account - switch to another account first"
}

get_active_account

Get currently active account info.

Request:

{
  "id": "req-014",
  "method": "get_active_account"
}

Response:

{
  "id": "req-014",
  "pubkey": "abc123...",
  "npub": "npub1abc...",
  "is_unlocked": true
}

Daemon Control Methods

shutdown_daemon

Gracefully shutdown the daemon.

Request:

{
  "id": "req-020",
  "method": "shutdown_daemon"
}

Response:

{
  "id": "req-020",
  "signature": "success"
}

enable_autostart

Enable daemon autostart on system boot.

Request:

{
  "id": "req-021",
  "method": "enable_autostart"
}

Response:

{
  "id": "req-021",
  "signature": "success"
}

disable_autostart

Disable daemon autostart.

Request:

{
  "id": "req-022",
  "method": "disable_autostart"
}

Response:

{
  "id": "req-022",
  "signature": "success"
}

get_autostart_status

Check if autostart is enabled.

Request:

{
  "id": "req-023",
  "method": "get_autostart_status"
}

Response:

{
  "id": "req-023",
  "signature": "enabled"
}

or

{
  "id": "req-023",
  "signature": "disabled"
}

Client Integration Guide

JavaScript/TypeScript Example

import * as net from 'net';
import * as os from 'os';
import * as path from 'path';

const socketPath = path.join(os.homedir(), '.noorsigner', 'noorsigner.sock');

function sendRequest(method: string, params: Record<string, any> = {}): Promise<any> {
  return new Promise((resolve, reject) => {
    const client = net.createConnection(socketPath);

    const request = {
      id: `req-${Date.now()}`,
      method,
      ...params
    };

    client.on('connect', () => {
      client.write(JSON.stringify(request) + '\n');
    });

    client.on('data', (data) => {
      const response = JSON.parse(data.toString());
      client.end();

      if (response.error) {
        reject(new Error(response.error));
      } else {
        resolve(response);
      }
    });

    client.on('error', reject);
  });
}

// Get current account
const active = await sendRequest('get_active_account');
console.log('Active account:', active.npub);

// List all accounts
const list = await sendRequest('list_accounts');
console.log('Accounts:', list.accounts.length);

// Switch account
const switched = await sendRequest('switch_account', {
  pubkey: 'target-pubkey-hex',
  password: 'password-for-target'
});

// Sign event
const signed = await sendRequest('sign_event', {
  event_json: JSON.stringify({
    content: 'Hello Nostr',
    kind: 1,
    tags: [],
    created_at: Math.floor(Date.now() / 1000)
  })
});
console.log('Signature:', signed.signature);

GUI Integration Guide (Terminal-Free)

This section explains how to fully manage NoorSigner from a GUI application without ever opening a terminal. This is the recommended approach for desktop Nostr clients.

Overview

NoorSigner provides three interfaces for programmatic control:

Interface Use Case
Filesystem checks Detect accounts, trust session validity
CLI with --stdin flags Add accounts, start daemon with password
Unix socket API All runtime operations (sign, switch, etc.)

Decision Flow

When a user clicks "Login with NoorSigner" in your client:

Is daemon running? (check socket exists + responds)
  YES → getPubkey → Login complete
  NO  →
    Any accounts in ~/.noorsigner/accounts/?
      NO  → Show "Import Key" UI (nsec + password)
            → add-account --stdin
            → daemon --password-stdin
            → Login complete

      YES → Is trust session valid?
              YES → Start daemon silently (no stdin needed)
                    → Login complete
              NO  → Show "Enter Password" UI
                    → daemon --password-stdin
                    → Login complete

Step 1: Check Daemon Status

Check if the daemon is running by testing the socket:

import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as net from 'net';

const NOORSIGNER_DIR = path.join(os.homedir(), '.noorsigner');
const SOCKET_PATH = path.join(NOORSIGNER_DIR, 'noorsigner.sock');

function isDaemonRunning(): Promise<boolean> {
  if (!fs.existsSync(SOCKET_PATH)) return Promise.resolve(false);

  return new Promise((resolve) => {
    const client = net.createConnection(SOCKET_PATH);
    client.on('connect', () => {
      client.end();
      resolve(true);
    });
    client.on('error', () => resolve(false));
    setTimeout(() => { client.destroy(); resolve(false); }, 1000);
  });
}

Step 2: Check Filesystem State

Check if any accounts exist:

function hasAccounts(): boolean {
  const accountsDir = path.join(NOORSIGNER_DIR, 'accounts');
  if (!fs.existsSync(accountsDir)) return false;

  const entries = fs.readdirSync(accountsDir, { withFileTypes: true });
  return entries.some(e => e.isDirectory());
}

Check if trust session is valid (active account):

function isTrustSessionValid(): boolean {
  const activeAccountFile = path.join(NOORSIGNER_DIR, 'active_account');
  if (!fs.existsSync(activeAccountFile)) return false;

  const activeNpub = fs.readFileSync(activeAccountFile, 'utf-8').trim();
  if (!activeNpub) return false;

  const trustSessionFile = path.join(
    NOORSIGNER_DIR, 'accounts', activeNpub, 'trust_session'
  );
  if (!fs.existsSync(trustSessionFile)) return false;

  // Trust sessions expire after 24 hours
  const stats = fs.statSync(trustSessionFile);
  const ageMs = Date.now() - stats.mtimeMs;
  return ageMs < 24 * 60 * 60 * 1000;
}

Step 3: Add Account (No Terminal)

When no accounts exist, collect nsec + password from your UI and pipe them via CLI:

import { spawn } from 'child_process';

function addAccount(
  noorsignerPath: string,
  nsec: string,
  password: string
): Promise<{ success: boolean; npub: string; pubkey: string }> {
  return new Promise((resolve, reject) => {
    const child = spawn(noorsignerPath, ['add-account', '--stdin']);
    let output = '';

    child.stdin.write(JSON.stringify({ nsec, password }));
    child.stdin.end();

    child.stdout.on('data', (data) => { output += data.toString(); });
    child.stderr.on('data', (data) => { output += data.toString(); });

    child.on('close', (code) => {
      try {
        const result = JSON.parse(output);
        resolve(result);
      } catch {
        reject(new Error(`Exit code ${code}: ${output}`));
      }
    });
  });
}

This creates the account, encrypts the key, and sets up a trust session — the daemon can then start without a password prompt.

Step 4: Start Daemon (No Terminal)

With valid trust session (no password needed):

function startDaemonSilent(noorsignerPath: string): void {
  spawn(noorsignerPath, ['daemon'], {
    detached: true,
    stdio: ['ignore', 'ignore', 'ignore']
  }).unref();
}

With password (trust session expired):

function startDaemonWithPassword(
  noorsignerPath: string,
  password: string
): Promise<'success' | 'invalid_password'> {
  return new Promise((resolve, reject) => {
    const child = spawn(noorsignerPath, ['daemon', '--password-stdin'], {
      detached: true
    });

    let output = '';
    child.stdout?.on('data', (data) => { output += data.toString(); });
    child.stderr?.on('data', (data) => { output += data.toString(); });

    child.stdin.write(password + '\n');
    child.stdin.end();

    child.on('close', (code) => {
      if (code === 0) {
        resolve('success');
      } else if (output.includes('Invalid password')) {
        resolve('invalid_password');
      } else {
        reject(new Error(`Daemon failed: ${output}`));
      }
    });

    // Daemon forks to background — parent exits quickly
    setTimeout(() => resolve('success'), 5000);
  });
}

Step 5: Wait for Socket

After launching the daemon, poll for the socket before making API calls:

async function waitForSocket(timeoutMs = 5000): Promise<boolean> {
  const start = Date.now();
  while (Date.now() - start < timeoutMs) {
    if (await isDaemonRunning()) return true;
    await new Promise(r => setTimeout(r, 200));
  }
  return false;
}

Step 6: Switch Account After Import

When adding a new account while the daemon is already running, add_account via the socket API only saves to disk — it does not switch the daemon's in-memory key. You must call switch_account afterwards:

// 1. Add account via daemon API
const addResult = await sendRequest('add_account', {
  nsec: 'nsec1...',
  password: 'user-password',
  set_active: true
});

// 2. Switch in-memory key (required!)
await sendRequest('switch_account', {
  npub: addResult.npub,
  password: 'user-password'
});

// Now getPubkey returns the new account

Complete Example: Login Flow

async function loginWithNoorSigner(noorsignerPath: string) {
  // 1. Daemon already running?
  if (await isDaemonRunning()) {
    const account = await sendRequest('get_npub');
    return { success: true, npub: account.signature };
  }

  // 2. Any accounts?
  if (!hasAccounts()) {
    // Show your "Import Key" UI → collect nsec + password
    const { nsec, password } = await showImportKeyDialog();
    await addAccount(noorsignerPath, nsec, password);
    // Trust session created by add-account --stdin
    startDaemonSilent(noorsignerPath);
    await waitForSocket();
    const account = await sendRequest('get_npub');
    return { success: true, npub: account.signature };
  }

  // 3. Trust session valid?
  if (isTrustSessionValid()) {
    startDaemonSilent(noorsignerPath);
    await waitForSocket();
    const account = await sendRequest('get_npub');
    return { success: true, npub: account.signature };
  }

  // 4. Trust session expired → need password
  const { password } = await showPasswordDialog();
  const result = await startDaemonWithPassword(noorsignerPath, password);

  if (result === 'invalid_password') {
    return { success: false, error: 'Incorrect password' };
  }

  await waitForSocket();
  const account = await sendRequest('get_npub');
  return { success: true, npub: account.signature };
}

Security Model

Encryption

  • Key Storage: NIP-49 compatible scrypt encryption (N=16384, r=8, p=1)
  • Per-Account Passwords: Each account has its own encryption password
  • Trust Mode: Session token encrypted with random 32-byte key
  • Memory Safety: Keys zeroed out after use and on account switch

Trust Mode (24 Hours)

When daemon starts or switches accounts:

  • Caches the decrypted nsec encrypted with a random session token
  • Expires after 24 hours from creation
  • Stored in account-specific trust_session file
  • Allows daemon to restart without password re-entry (within 24h)

Security Trade-off: Trust Mode trades security for convenience. Only use on devices you trust.

Socket Permissions

The Unix socket is created with 0600 permissions (owner read/write only), preventing other users from accessing it.

Account Switch Security

  • Old private key is zeroed from memory before loading new key
  • New account requires password verification
  • New Trust Mode session created for switched account

Platform-Specific Notes

macOS

  • Socket path: ~/.noorsigner/noorsigner.sock
  • Autostart: LaunchAgent (~/Library/LaunchAgents/com.noorsigner.daemon.plist)
  • Daemon launches via Terminal.app when called from GUI

Linux

  • Socket path: ~/.noorsigner/noorsigner.sock
  • Autostart: XDG Autostart (~/.config/autostart/noorsigner.desktop)
  • Same daemon behavior as macOS

Building from Source

# Clone repository
git clone https://github.com/77elements/noorsigner.git
cd noorsigner

# Build for current platform
go build -o noorsigner .

# Or build all platforms
./build.sh

# Binaries will be in ./bin/

Troubleshooting

Daemon not starting

  1. Check if socket already exists: ls -la ~/.noorsigner/noorsigner.sock
  2. Remove stale socket: rm ~/.noorsigner/noorsigner.sock
  3. Check for running processes: ps aux | grep noorsigner
  4. Kill existing daemon: pkill noorsigner

"Failed to connect to daemon"

  • Daemon might not be running
  • Socket file might not exist
  • Check socket path matches your platform
  • Verify socket permissions: ls -la ~/.noorsigner/

"Invalid password"

  • Encryption password is wrong
  • Key file might be corrupted
  • Use correct password for the specific account

"Account not found"

  • Account was removed or never created
  • Use list-accounts to see available accounts
  • Use add-account to create a new account

Trust Mode not working after reboot

  • This is expected! Trust Mode sessions expire after 24 hours OR system reboot
  • Simply restart daemon and enter password again

License

MIT License - See LICENSE file for details


Contributing

Contributions welcome! Please:

  1. Follow Go best practices
  2. Maintain backwards compatibility with existing clients
  3. Add tests for new features
  4. Update this README with API changes

Roadmap

  • Multi-account support
  • Live account switching via API
  • NIP-44 encryption/decryption
  • NIP-04 encryption/decryption
  • Auto-launch on system startup (macOS/Linux)
  • NIP-46 Remote Signer support
  • Hardware wallet integration
  • Custom Trust Mode duration
  • GUI integration (terminal-free operation via --password-stdin)

Support

For issues, feature requests, or questions:

  • GitHub Issues: Project Issues
  • Nostr: Contact the maintainers on Nostr

Made with ⚡ for the Nostr ecosystem

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published