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.
Step 1: Add your first account
./noorsigner add-accountYou'll be asked for:
- Your nsec (private key) - input is hidden for security
- A password (8+ characters) - used to encrypt your key
That's it! Your key is now safely stored.
Step 2: Start the daemon
./noorsigner daemonEnter 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!
Want to use multiple Nostr identities? Just add more accounts:
./noorsigner add-accountEach account has its own password.
See all your accounts:
./noorsigner list-accountsOutput 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.
./noorsigner remove-account npub1def...You'll need to enter the account's password to confirm.
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.
| 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 |
The following sections are for developers and advanced users.
- 🔐 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
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 |
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
git clone https://github.com/77elements/noorsigner.git
cd noorsigner
go build -o noorsigner .See Building from Source for multi-platform builds.
./noorsigner add-accountThis 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.
./noorsigner daemonThis 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
Your Nostr client can now communicate with the daemon via the Unix socket.
# 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 initThe --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 --stdinExample (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);
}
});# Start the signing daemon (interactive password prompt)
noorsigner daemon
# Start daemon with password piped via stdin (for GUI integration)
echo "mypassword" | noorsigner daemon --password-stdinThe --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# 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>~/.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)
- Each account has its own directory under
accounts/ - Each account has separate encryption password
- Each account has its own Trust Mode session
- One daemon instance serves all accounts
- Live account switching via API (password required)
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
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
}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"}
Get the hex public key of the currently active account.
Request:
{
"id": "req-001",
"method": "get_pubkey"
}Response:
{
"id": "req-001",
"signature": "abc123def456..."
}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 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"
}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"
}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"
}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"
}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"
}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 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 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 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 currently active account info.
Request:
{
"id": "req-014",
"method": "get_active_account"
}Response:
{
"id": "req-014",
"pubkey": "abc123...",
"npub": "npub1abc...",
"is_unlocked": true
}Gracefully shutdown the daemon.
Request:
{
"id": "req-020",
"method": "shutdown_daemon"
}Response:
{
"id": "req-020",
"signature": "success"
}Enable daemon autostart on system boot.
Request:
{
"id": "req-021",
"method": "enable_autostart"
}Response:
{
"id": "req-021",
"signature": "success"
}Disable daemon autostart.
Request:
{
"id": "req-022",
"method": "disable_autostart"
}Response:
{
"id": "req-022",
"signature": "success"
}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"
}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);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.
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.) |
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
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);
});
}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;
}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.
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);
});
}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;
}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 accountasync 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 };
}- 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
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_sessionfile - 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.
The Unix socket is created with 0600 permissions (owner read/write only), preventing other users from accessing it.
- Old private key is zeroed from memory before loading new key
- New account requires password verification
- New Trust Mode session created for switched account
- Socket path:
~/.noorsigner/noorsigner.sock - Autostart: LaunchAgent (
~/Library/LaunchAgents/com.noorsigner.daemon.plist) - Daemon launches via Terminal.app when called from GUI
- Socket path:
~/.noorsigner/noorsigner.sock - Autostart: XDG Autostart (
~/.config/autostart/noorsigner.desktop) - Same daemon behavior as macOS
# 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/- Check if socket already exists:
ls -la ~/.noorsigner/noorsigner.sock - Remove stale socket:
rm ~/.noorsigner/noorsigner.sock - Check for running processes:
ps aux | grep noorsigner - Kill existing daemon:
pkill noorsigner
- Daemon might not be running
- Socket file might not exist
- Check socket path matches your platform
- Verify socket permissions:
ls -la ~/.noorsigner/
- Encryption password is wrong
- Key file might be corrupted
- Use correct password for the specific account
- Account was removed or never created
- Use
list-accountsto see available accounts - Use
add-accountto create a new account
- This is expected! Trust Mode sessions expire after 24 hours OR system reboot
- Simply restart daemon and enter password again
MIT License - See LICENSE file for details
Contributions welcome! Please:
- Follow Go best practices
- Maintain backwards compatibility with existing clients
- Add tests for new features
- Update this README with API changes
- 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)
For issues, feature requests, or questions:
- GitHub Issues: Project Issues
- Nostr: Contact the maintainers on Nostr
Made with ⚡ for the Nostr ecosystem