adding a bunch of documentation to make sense of this tool

This commit is contained in:
rabble 2024-12-09 23:19:31 +13:00
parent 114cb73ef5
commit 9dfd83f62e
26 changed files with 755 additions and 115 deletions

View File

@ -1,29 +1,34 @@
FROM node:20.11-bullseye AS build
WORKDIR /app
# Copy and install all dependencies
# Copy package files and install dependencies
COPY package*.json ./
COPY tsconfig*.json ./
RUN npm install -g npm@latest && npm install
RUN npm install
# Copy application files
COPY . .
# Generate prisma client and build the application
RUN npx prisma generate
RUN npm run build
# Optional: prune dev dependencies after build
RUN npm prune --omit=dev
# Runtime stage
FROM node:20.11-alpine as runtime
WORKDIR /app
RUN apk update && \
apk add --no-cache openssl && \
rm -rf /var/cache/apk/*
# Copy only what is needed
COPY --from=build /app/package*.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules/.prisma ./node_modules/.prisma
# Copy built files from the build stage
COPY --from=build /app .
# Install only runtime dependencies
RUN npm install --only=production
EXPOSE 3000
ENTRYPOINT [ "node", "./dist/index.js" ]
CMD ["start"]

View File

@ -1,18 +1,26 @@
/**
* CLI client for interacting with nsecBunker
* Supports:
* - Signing events (NIP-46)
* - Creating new accounts
* - Managing authorization flows
* - Publishing to relays
*
* Uses NDK for Nostr protocol interactions
* This client provides command-line functionality for:
* - Signing events using NIP-46 protocol
* - Creating new Nostr accounts
* - Managing authorization flows
* - Publishing signed events to relays
*
* Security Model:
* - Uses local private key for client authentication
* - Communicates with nsecBunker through encrypted channels
* - Supports NIP-05 identifier resolution
* - Implements NIP-46 signing protocol
*
* @module nsecbunker-client
*/
import "websocket-polyfill";
import NDK, { NDKUser, NDKEvent, NDKPrivateKeySigner, NDKNip46Signer, NostrEvent } from '@nostr-dev-kit/ndk';
import fs from 'fs';
// Command line argument parsing
const args = process.argv;
const command = process.argv[2];
let remotePubkey = process.argv[3];
@ -23,6 +31,7 @@ let signer: NDKNip46Signer;
let ndk: NDK;
let remoteUser: NDKUser;
// Parse relay list from command line arguments
const relaysIndex = args.findIndex(arg => arg === '--relays');
let relays: string[] = [];
@ -30,6 +39,9 @@ if (relaysIndex !== -1 && args[relaysIndex + 1]) {
relays = args[relaysIndex + 1].split(',');
}
/**
* Display usage instructions when no command is provided
*/
if (!command) {
console.log('Usage: node src/client.js <command> <remote-npub> <content> [--dont-publish] [--debug] [--pk <key>]');
console.log('');
@ -42,12 +54,16 @@ if (!command) {
process.exit(1);
}
/**
* Creates and configures a new NDK instance
* Connects to nsecBunker relay and any additional specified relays
*/
async function createNDK(): Promise<NDK> {
const ndk = new NDK({
explicitRelayUrls: [
'wss://relay.nsecbunker.com',
...relays
],
'wss://relay.nsecbunker.com',
...relays
],
enableOutboxModel: false
});
if (debug) {
@ -58,15 +74,19 @@ async function createNDK(): Promise<NDK> {
return ndk;
}
// switch (command) {
// case 'ping':
// ping(remotePubkey);
/**
* Gets the path for storing the client's private key
* Uses home directory for cross-platform compatibility
*/
function getPrivateKeyPath() {
const home = process.env.HOME || process.env.USERPROFILE;
return `${home}/.nsecbunker-client-private.key`;
}
/**
* Saves the client's private key to disk
* Creates directory if it doesn't exist
*/
function savePrivateKey(pk: string) {
const path = getPrivateKeyPath();
if (!fs.existsSync(path)) {
@ -75,6 +95,10 @@ function savePrivateKey(pk: string) {
fs.writeFileSync(`${path}/private.key`, pk);
}
/**
* Loads the client's private key from disk
* Returns undefined if key doesn't exist
*/
function loadPrivateKey(): string | undefined {
const path = getPrivateKeyPath();
if (!fs.existsSync(path)) {
@ -83,15 +107,14 @@ function loadPrivateKey(): string | undefined {
return fs.readFileSync(`${path}/private.key`).toString();
}
// Main execution block
(async () => {
let remoteUser: NDKUser;
ndk = await createNDK();
// if this is the create_account command and we have something that doesn't look like an npub as the remotePubkey, use NDKUser.fromNip05 to get the npub
// Handle NIP-05 identifier resolution
if (command === 'create_account' && !remotePubkey.startsWith("npub")) {
// see if we have a username@domain
let [ username, domain ] = remotePubkey.split('@');
if (!domain) {
@ -109,7 +132,7 @@ function loadPrivateKey(): string | undefined {
remoteUser = u;
remotePubkey = remoteUser.pubkey;
} else {
// check if we have a @ so we try to get the npub from nip05
// Handle NIP-05 resolution for existing accounts
if (remotePubkey.includes('@')) {
const u = await NDKUser.fromNip05(remotePubkey);
if (!u) {
@ -123,9 +146,8 @@ function loadPrivateKey(): string | undefined {
}
}
// Initialize local signer
let localSigner: NDKPrivateKeySigner;
const pk = loadPrivateKey();
if (pk) {
@ -135,15 +157,18 @@ function loadPrivateKey(): string | undefined {
savePrivateKey(localSigner.privateKey!);
}
// Setup NIP-46 signer and NDK instance
signer = new NDKNip46Signer(ndk, remoteUser.pubkey, localSigner);
if (debug) console.log(`local pubkey`, (await localSigner.user()).npub);
if (debug) console.log(`remote pubkey`, remotePubkey);
ndk.signer = signer;
// Handle OAuth-like authorization flow
signer.on("authUrl", (url) => {
console.log(`Go to ${url} to authorize this request`);
});
// Route to appropriate command handler
switch (command) {
case "sign": return signFlow();
case "create_account": return createAccountFlow();
@ -153,6 +178,11 @@ function loadPrivateKey(): string | undefined {
}
})();
/**
* Handles account creation flow
* Parses username, domain, and optional email
* Creates new account through nsecBunker
*/
async function createAccountFlow() {
const [ username, domain, email ] = content.split(',').map((s) => s.trim());
try {
@ -164,6 +194,11 @@ async function createAccountFlow() {
}
}
/**
* Handles event signing flow
* Supports both JSON event objects and simple text content
* Optionally publishes signed events to relays
*/
function signFlow() {
setTimeout(async () => {
try {
@ -177,6 +212,7 @@ function signFlow() {
let event;
// Parse event from JSON or create new kind:1 event
try {
const json = JSON.parse(content);
event = new NDKEvent(ndk, json);
@ -193,6 +229,7 @@ function signFlow() {
} as NostrEvent);
}
// Sign and optionally publish event
try {
await event.sign();
if (debug) {
@ -201,9 +238,9 @@ function signFlow() {
console.log(event.sig);
}
if (!dontPublish) {
const relaysPublished = await event.publish();
}
if (!dontPublish) {
const relaysPublished = await event.publish();
}
process.exit(0);
} catch(e) {

View File

@ -16,6 +16,13 @@ interface IOpts {
name: string;
}
/**
* Encrypts and saves an nsec to the configuration
* @param config Path to config file
* @param nsec The nsec to encrypt
* @param passphrase Encryption passphrase
* @param name Key name identifier
*/
export async function saveEncrypted(config: string, nsec: string, passphrase: string, name: string) {
const { iv, data } = encryptNsec(nsec, passphrase);
const currentConfig = await getCurrentConfig(config);
@ -25,6 +32,10 @@ export async function saveEncrypted(config: string, nsec: string, passphrase: st
saveCurrentConfig(config, currentConfig);
}
/**
* Interactive command to add a new nsec
* Prompts user for passphrase and nsec
*/
export async function addNsec(opts: IOpts) {
const name = opts.name;
const config = opts.config;
@ -34,19 +45,16 @@ export async function addNsec(opts: IOpts) {
output: process.stdout
});
// Prompt for passphrase and nsec
console.log(`nsecBunker uses a passphrase to encrypt your nsec when stored on-disk.\n` +
`Every time you restart it, you will need to type in this password.` +
`\n`);
rl.question(`Enter a passphrase: `, (passphrase: string) => {
rl.question(`Enter the nsec for ${name}: `, (nsec: string) => {
let decoded;
try {
decoded = nip19.decode(nsec);
// const hexpubkey = getPublicKey(decoded.data as string);
// const npub = nip19.npubEncode(hexpubkey);
saveEncrypted(config, nsec, passphrase, name);
rl.close();
} catch (e: any) {
console.log(e.message);

View File

@ -1,12 +1,20 @@
/**
* Initial setup command for nsecBunker
* Configures the first administrator npub that will have
* remote control access to the bunker
* Initial Setup Command
*
* Handles first-time setup of nsecBunker:
* - Administrator configuration
* - Initial npub registration
* - Basic security settings
*/
import readline from 'readline';
import { getCurrentConfig, saveCurrentConfig } from '../config/index.js';
/**
* Runs the initial setup process
* Prompts for administrator npub and saves to config
* @param config - Path to config file
*/
export async function setup(config: string) {
const currentConfig = await getCurrentConfig(config);
const rl = readline.createInterface({
@ -16,9 +24,9 @@ export async function setup(config: string) {
console.log(`You need at least one administrator to remotely control nsecBunker. This should probably be your own npub.\n`);
// Prompt for admin npub
rl.question(`Enter an administrator npub: `, (npub: string) => {
currentConfig.admin.npubs.push(npub);
saveCurrentConfig(config, currentConfig);
rl.close();

View File

@ -113,8 +113,8 @@ async function nip89announcement(configData: IConfig) {
}
/**
* This command starts the nsecbunkerd process with an (optional)
* admin interface over websockets or redis.
* Main start command implementation
* @param opts Configuration options for starting the daemon
*/
export async function start(opts: IOpts) {
const configData = await getCurrentConfig(opts.config);

View File

@ -1,17 +1,20 @@
/**
* Configuration management for nsecBunker
* Handles reading/writing config files and provides type definitions
* for the configuration schema
*
* This module handles the reading, writing, and type definitions for the nsecbunker.json
* configuration file. The configuration controls all aspects of the bunker's operation,
* including admin access, relay connections, and key storage.
*/
import { readFileSync, writeFileSync } from 'fs';
import { NDKPrivateKeySigner, NDKUserProfile } from '@nostr-dev-kit/ndk';
import { IAdminOpts } from '../daemon/admin';
import { version } from '../../package.json';
// Generate a default private key for bunker communication
const generatedKey = NDKPrivateKeySigner.generate();
// LNBits wallet integration configuration
export type LNBitsWalletConfig = {
url: string,
key: string,
@ -22,7 +25,12 @@ export interface IWalletConfig {
lnbits?: LNBitsWalletConfig;
}
/**
* Configuration for individual domains
* Used when creating new users and managing NIP-05 verifications
*/
export interface DomainConfig {
// The file pointing to the domain's NIP-05 verification
nip05: string;
nip89?: {
profile: Record<string, string>;
@ -33,21 +41,41 @@ export interface DomainConfig {
defaultProfile?: Record<string, string>;
};
/**
* Main configuration interface for nsecBunker
* All properties are optional unless otherwise noted in the documentation
*/
export interface IConfig {
// Nostr relay configuration for NIP-46 requests
nostr: {
relays: string[];
};
// Admin configuration for bunker management
admin: IAdminOpts;
// Port for OAuth-like authentication flow
authPort?: number;
// Host for OAuth-like authentication flow
authHost?: string;
// Database URI for storing bunker data
database: string;
// Path for log file storage
logs: string;
// Storage for encrypted and unencrypted keys
// Format: keys.$keyId.iv + keys.$keyId.data (encrypted)
// keys.$keyId.key (unencrypted)
keys: Record<string, any>;
// URL for OAuth-like authentication access
baseUrl?: string;
// Enable detailed logging when true
verbose: boolean;
// Allowed domains for user creation
domains?: Record<string, DomainConfig>;
}
/**
* Default configuration used when no config file exists
* Provides minimal setup for basic bunker operation
*/
const defaultConfig: IConfig = {
nostr: {
relays: [
@ -56,24 +84,29 @@ const defaultConfig: IConfig = {
]
},
admin: {
npubs: [],
adminRelays: [
npubs: [], // Admin NPUBs allowed to manage the bunker
adminRelays: [ // Relays for admin commands
"wss://relay.nsecbunker.com"
],
key: generatedKey.privateKey!,
key: generatedKey.privateKey!, // Auto-generated bunker private key
notifyAdminsOnBoot: true,
},
database: 'sqlite://nsecbunker.db',
logs: './nsecbunker.log',
keys: {},
keys: {}, // Empty key storage by default
verbose: false,
};
/**
* Reads and processes the configuration file
* Creates default config if none exists
* Ensures all required properties are present
*/
async function getCurrentConfig(config: string): Promise<IConfig> {
try {
const configFileContents = readFileSync(config, 'utf8');
// add new config options to the config file
// Update config with current version and defaults
const currentConfig = JSON.parse(configFileContents);
currentConfig.version = version;
currentConfig.admin.notifyAdminsOnBoot ??= true;
@ -81,15 +114,20 @@ async function getCurrentConfig(config: string): Promise<IConfig> {
return currentConfig;
} catch (err: any) {
if (err.code === 'ENOENT') {
// Create new config file with defaults if none exists
await saveCurrentConfig(config, defaultConfig);
return defaultConfig;
} else {
console.error(`Error reading config file: ${err.message}`);
process.exit(1); // Kill the process if there is an error parsing the JSON
process.exit(1);
}
}
}
/**
* Saves the current configuration to disk
* Automatically includes the current bunker version
*/
export function saveCurrentConfig(config: string, currentConfig: any) {
try {
currentConfig.version = version;
@ -97,8 +135,8 @@ export function saveCurrentConfig(config: string, currentConfig: any) {
writeFileSync(config, configString);
} catch (err: any) {
console.error(`Error writing config file: ${err.message}`);
process.exit(1); // Kill the process if there is an error parsing the JSON
process.exit(1);
}
}
export {getCurrentConfig};

View File

@ -1,9 +1,24 @@
/**
* Wallet management functionality for user accounts.
* Handles wallet generation and LN Address creation through LNBits integration.
*/
import axios from "axios";
import createDebug from "debug";
import { IWalletConfig, LNBitsWalletConfig } from "../../../../config";
const debug = createDebug("nsecbunker:wallet");
/**
* Generates a wallet based on the provided configuration.
* Currently supports LNBits wallet generation.
*
* @param walletConfig - Wallet configuration object
* @param username - User's username
* @param domain - Domain name
* @param npub - User's nostr public key
* @returns Lightning address in the format username@domain
*/
export async function generateWallet(
walletConfig: IWalletConfig,
username: string,
@ -16,6 +31,16 @@ export async function generateWallet(
}
}
/**
* Creates a new wallet in LNBits for the specified user.
*
* @param lnbitsConfig - LNBits-specific configuration
* @param username - User's username
* @param domain - Domain name
* @param npub - User's nostr public key
* @returns Lightning address in the format username@domain
* @throws Will throw an error if the LNBits API request fails
*/
export async function generateLNBitsWallet(
lnbitsConfig: LNBitsWalletConfig,
username: string,
@ -52,6 +77,19 @@ export async function generateLNBitsWallet(
);
}
/**
* Generates a Lightning Address by registering with a nostdress server.
*
* @param username - User's username
* @param domain - Domain name
* @param userInvoiceKey - LNBits invoice/read key
* @param userNpub - User's nostr public key
* @param kind - Type of Lightning implementation (e.g., 'lnbits')
* @param host - Base URL of the Lightning implementation
* @param nostdressUrl - URL of the nostdress server
* @returns Lightning address in the format username@domain
* @throws Will throw an error if the nostdress API request fails
*/
export async function generateLNAddress(
username: string,
domain: string,

View File

@ -12,6 +12,22 @@ import createDebug from "debug";
const debug = createDebug("nsecbunker:createAccount");
/**
* Account Creation Command Handler
*
* Handles the creation of new user accounts.
* Features:
* - Key generation
* - Initial permissions setup
* - Profile creation
*/
/**
* Validates the creation of a new nostr account by checking:
* 1. Username is provided
* 2. Domain exists in config
* 3. Username isn't already taken in the domain's nip05 file
*/
export async function validate(currentConfig, username: string, domain: string, email?: string) {
if (!username) {
throw new Error('username is required');
@ -35,6 +51,11 @@ const emptyNip05File = {
relays: {},
}
/**
* Retrieves the current NIP-05 verification file for a domain
* NIP-05 files map usernames to their corresponding nostr public keys
* and contain relay information for the domain
*/
async function getCurrentNip05File(currentConfig: any, domain: string) {
try {
const nip05File = currentConfig.domains[domain].nip05;
@ -46,7 +67,8 @@ async function getCurrentNip05File(currentConfig: any, domain: string) {
}
/**
* Adds an entry to the nip05 file for the domain
* Updates the domain's NIP-05 file with a new username->pubkey mapping
* Also adds relay information for NIP-46 (Nostr Connect) support
*/
async function addNip05(currentConfig: IConfig, username: string, domain: string, pubkey: Hexpubkey) {
const currentNip05s = await getCurrentNip05File(currentConfig, domain);
@ -99,6 +121,12 @@ async function validateDomain(domain: string | undefined, admin: AdminInterface,
return domain;
}
/**
* Main entry point for account creation
* 1. Validates username and domain
* 2. Requests authorization for the creation
* 3. If authorized, calls createAccountReal to perform the actual creation
*/
export default async function createAccount(admin: AdminInterface, req: NDKRpcRequest) {
let [ username, domain, email ] = req.params as [ string?, string?, string? ];
@ -136,7 +164,19 @@ export default async function createAccount(admin: AdminInterface, req: NDKRpcRe
}
/**
* This is where the real work of creating the private key, wallet, nip-05, granting access, etc happen
* Performs the actual account creation after authorization:
* 1. Generates a new key pair for the user
* 2. Creates and saves NIP-05 verification
* 3. Sets up a lightning wallet if configured
* 4. Creates initial nostr profile
* 5. Saves the private key in the bunker's config
* 6. Grants initial permissions to the requesting client
*
* @param admin - The admin interface for managing the bunker
* @param req - The original RPC request
* @param username - Validated username
* @param domain - Validated domain
* @param email - Optional email for the account
*/
export async function createAccountReal(
admin: AdminInterface,
@ -217,6 +257,13 @@ export async function createAccountReal(
}
}
/**
* Grants initial permissions to the key that created the account
* Allows for:
* - Nostr Connect authentication
* - Signing events (all kinds)
* - Encryption/decryption operations
*/
async function grantPermissions(req: NDKRpcRequest, keyName: string) {
await allowAllRequestsFromKey(req.pubkey, keyName, "connect");
await allowAllRequestsFromKey(req.pubkey, keyName, "sign_event", undefined, undefined, { kind: 'all' });

View File

@ -4,27 +4,40 @@ import { saveEncrypted } from "../../../commands/add.js";
import { nip19 } from 'nostr-tools';
import { setupSkeletonProfile } from "../../lib/profile.js";
/**
* Creates a new Nostr key or imports an existing one, saves it encrypted, and sets up a basic profile
* @param {AdminInterface} admin - The admin interface instance handling the request
* @param {NDKRpcRequest} req - The RPC request containing the parameters
* @returns {Promise<void>} - Returns the RPC response with the new npub
* @throws {Error} If required parameters are missing or if unlockKey method is not available
*/
export default async function createNewKey(admin: AdminInterface, req: NDKRpcRequest) {
// Extract parameters: keyName (for storage), passphrase (for encryption), and optional nsec (existing key)
const [ keyName, passphrase, _nsec ] = req.params as [ string, string, string? ];
// Validate required parameters
if (!keyName || !passphrase) throw new Error("Invalid params");
if (!admin.loadNsec) throw new Error("No unlockKey method");
let key;
if (_nsec) {
// Import existing key from nsec
key = new NDKPrivateKeySigner(nip19.decode(_nsec).data as string);
} else {
// Generate new key pair
key = NDKPrivateKeySigner.generate();
// Create basic profile for new key
setupSkeletonProfile(key);
console.log(`setting up skeleton profile for ${keyName}`);
}
// Get user information and encode private key
const user = await key.user();
const nsec = nip19.nsecEncode(key.privateKey!);
// Save the encrypted key to config
await saveEncrypted(
admin.configFile,
nsec,
@ -32,8 +45,10 @@ export default async function createNewKey(admin: AdminInterface, req: NDKRpcReq
keyName
);
// Load the key into active use
await admin.loadNsec(keyName, nsec);
// Prepare and send response with public key
const result = JSON.stringify({
npub: user.npub,
});

View File

@ -1,7 +1,23 @@
/**
* Policy Creation Command Handler
*
* Manages the creation of new access policies.
* Features:
* - Policy naming and expiration
* - Rule creation and management
* - Usage tracking setup
*/
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import prisma from "../../../db.js";
/**
* Creates a new access policy with associated rules
* @param admin - Admin interface instance
* @param req - The RPC request containing policy details
* @returns Response indicating success
*/
export default async function createNewPolicy(admin: AdminInterface, req: NDKRpcRequest) {
const [ _policy ] = req.params as [ string ];
@ -9,6 +25,7 @@ export default async function createNewPolicy(admin: AdminInterface, req: NDKRpc
const policy = JSON.parse(_policy);
// Create the base policy record
const policyRecord = await prisma.policy.create({
data: {
name: policy.name,
@ -16,6 +33,7 @@ export default async function createNewPolicy(admin: AdminInterface, req: NDKRpc
}
});
// Create associated rules
for (const rule of policy.rules) {
await prisma.policyRule.create({
data: {

View File

@ -1,26 +1,57 @@
/**
* Token Creation Command Handler
*
* Manages the creation of access tokens.
* Features:
* - Token generation
* - Policy association
* - Expiration management
* - Client tracking
*/
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import prisma from "../../../db.js";
/**
* Creates a new access token with associated policy
* @param admin - Admin interface instance
* @param req - The RPC request containing token details
* @returns Response indicating success
*/
export default async function createNewToken(admin: AdminInterface, req: NDKRpcRequest) {
const [ keyName, clientName, policyId, durationInHours ] = req.params as [ string, string, string, string? ];
if (!clientName || !policyId) throw new Error("Invalid params");
const policy = await prisma.policy.findUnique({ where: { id: parseInt(policyId) }, include: { rules: true } });
// Validate policy exists
const policy = await prisma.policy.findUnique({
where: { id: parseInt(policyId) },
include: { rules: true }
});
if (!policy) throw new Error("Policy not found");
console.log({clientName, policy, durationInHours});
// Generate random token
const token = [...Array(64)].map(() => Math.floor(Math.random() * 16).toString(16)).join('');
// Prepare token data
const data: any = {
keyName, clientName, policyId,
keyName,
clientName,
policyId,
createdBy: req.pubkey,
token
};
if (durationInHours) data.expiresAt = new Date(Date.now() + (parseInt(durationInHours) * 60 * 60 * 1000));
// Add expiration if duration specified
if (durationInHours) {
data.expiresAt = new Date(Date.now() + (parseInt(durationInHours) * 60 * 60 * 1000));
}
// Create token record
const tokenRecord = await prisma.token.create({data});
if (!tokenRecord) throw new Error("Token not created");

View File

@ -1,26 +1,37 @@
/**
* Key User Rename Command Handler
*
* Manages the renaming of key users.
* Features:
* - User description updates
* - Key association management
*/
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import prisma from "../../../db.js";
/**
* Updates the description for a key user
* @param admin - Admin interface instance
* @param req - The RPC request containing new name
* @returns Response indicating success
*/
export default async function renameKeyUser(admin: AdminInterface, req: NDKRpcRequest) {
const [ keyUserPubkey, name ] = req.params as [ string, string ];
const [ keyUserId, description ] = req.params as [ string, string ];
if (!keyUserPubkey || !name) throw new Error("Invalid params");
if (!keyUserId || !description) throw new Error("Invalid params");
const keyUser = await prisma.keyUser.findFirst({
where: {
userPubkey: keyUserPubkey,
}
});
if (!keyUser) throw new Error("Key user not found");
const keyUserIdInt = parseInt(keyUserId);
if (isNaN(keyUserIdInt)) throw new Error("Invalid params");
// Update user description
await prisma.keyUser.update({
where: {
id: keyUser.id,
id: keyUserIdInt,
},
data: {
description: name,
description,
}
});

View File

@ -1,7 +1,23 @@
/**
* User Revocation Command Handler
*
* Manages the revocation of user access.
* Features:
* - User access revocation
* - Timestamp tracking
* - Permission removal
*/
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import prisma from "../../../db.js";
/**
* Revokes access for a specific user
* @param admin - Admin interface instance
* @param req - The RPC request containing user ID
* @returns Response indicating success
*/
export default async function revokeUser(admin: AdminInterface, req: NDKRpcRequest) {
const [ keyUserId ] = req.params as [ string ];
@ -10,6 +26,7 @@ export default async function revokeUser(admin: AdminInterface, req: NDKRpcReque
const keyUserIdInt = parseInt(keyUserId);
if (isNaN(keyUserIdInt)) throw new Error("Invalid params");
// Update user record with revocation timestamp
await prisma.keyUser.update({
where: {
id: keyUserIdInt,

View File

@ -1,6 +1,14 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
/**
* Handles key unlocking requests for the admin interface.
*
* @param {AdminInterface} admin - The admin interface instance handling the request
* @param {NDKRpcRequest} req - The RPC request containing the key name and passphrase
* @returns {Promise<void>} A promise that resolves when the response is sent
* @throws {Error} If params are invalid or unlockKey method is not available
*/
export default async function unlockKey(admin: AdminInterface, req: NDKRpcRequest) {
const [ keyName, passphrase ] = req.params as [ string, string ];

View File

@ -33,10 +33,17 @@ export type IAdminOpts = {
const allowNewKeys = true;
/**
* This class represents the admin interface for the nsecbunker daemon.
*
* It provides an interface for a UI to manage the daemon over nostr.
* Admin Interface Implementation
*
* This class provides the administrative interface for managing the nsecBunker daemon.
* It handles:
* - Request validation from admins
* - Key management
* - Token management
* - User management
* - Policy enforcement
*/
class AdminInterface {
private npubs: string[];
private ndk: NDK;
@ -162,21 +169,31 @@ class AdminInterface {
}
}
/**
* Validates incoming requests to ensure they're from authorized admins
* Allows create_account requests if new keys are enabled
* @param req - The incoming RPC request
*/
private async validateRequest(req: NDKRpcRequest): Promise<void> {
// if this request is of type create_account, allow it
// TODO: require some POW to prevent spam
// Allow create_account requests if enabled
if (req.method === 'create_account' && allowNewKeys) {
console.log(`allowing create_account request`);
return;
}
// Validate admin privileges
if (!await validateRequestFromAdmin(req, this.npubs)) {
throw new Error('You are not designated to administrate this bunker');
}
}
/**
* Command to list tokens
* Retrieves token information for a specific key
* Returns formatted token data including:
* - Token status
* - Policy information
* - User details
* @param req - The RPC request containing key name
*/
private async reqGetKeyTokens(req: NDKRpcRequest) {
const keyName = req.params[0];
@ -192,6 +209,7 @@ class AdminInterface {
},
});
// Format and return token data
const keys = await this.getKeys!();
const key = keys.find((k) => k.name === keyName);
@ -201,22 +219,20 @@ class AdminInterface {
const npub = key.npub;
const result = JSON.stringify(tokens.map((t) => {
return {
id: t.id,
key_name: t.keyName,
client_name: t.clientName,
token: [ npub, t.token ].join('#'),
policy_id: t.policyId,
policy_name: t.policy?.name,
created_at: t.createdAt,
updated_at: t.updatedAt,
expires_at: t.expiresAt,
redeemed_at: t.redeemedAt,
redeemed_by: t.KeyUser?.description,
time_until_expiration: t.expiresAt ? (t.expiresAt.getTime() - Date.now()) / 1000 : null,
};
}));
const result = JSON.stringify(tokens.map((t) => ({
id: t.id,
key_name: t.keyName,
client_name: t.clientName,
token: [ npub, t.token ].join('#'),
policy_id: t.policyId,
policy_name: t.policy?.name,
created_at: t.createdAt,
updated_at: t.updatedAt,
expires_at: t.expiresAt,
redeemed_at: t.redeemedAt,
redeemed_by: t.KeyUser?.description,
time_until_expiration: t.expiresAt ? (t.expiresAt.getTime() - Date.now()) / 1000 : null,
})));
return this.rpc.sendResponse(req.id, req.pubkey, result, 24134);
}

View File

@ -1,6 +1,22 @@
/**
* Admin Request Validation Module
*
* Validates incoming requests to ensure they come from authorized administrators.
* Handles:
* - Pubkey validation
* - Permission checking
* - Request authentication
*/
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import { nip19 } from "nostr-tools";
/**
* Validates that a request comes from an authorized admin
* @param req - The incoming RPC request
* @param npubs - List of authorized admin npubs
* @returns boolean indicating if request is valid
*/
export async function validateRequestFromAdmin(
req: NDKRpcRequest,
npubs: string[],
@ -12,6 +28,7 @@ export async function validateRequestFromAdmin(
return false;
}
// Convert npubs to hex format for comparison
const hexpubkeys = npubs.map((npub) => nip19.decode(npub).data as string);
return hexpubkeys.includes(hexpubkey);

View File

@ -10,7 +10,14 @@ let baseUrl: string | undefined | null;
/**
* Attempts to contact an admin to approve this request.
*
* Returns a promise that is resolved with true|false when a response has been received.
* @param admin - The admin interface instance to handle authorization
* @param keyName - Optional identifier for the key being authorized
* @param remotePubkey - The public key of the remote party requesting authorization
* @param requestId - Unique identifier for this authorization request
* @param method - The method being requested
* @param param - Optional parameters for the request, can be a string or NDKEvent
* @returns Promise resolving to a string when authorization is complete
* @throws Will reject if authorization is denied
*/
export async function requestAuthorization(
admin: AdminInterface,
@ -38,6 +45,17 @@ export async function requestAuthorization(
});
}
/**
* Handles the authorization flow when communicating directly with an admin
*
* @param adminInterface - The admin interface instance
* @param keyName - Optional identifier for the key being authorized
* @param remotePubkey - The public key of the remote party
* @param method - The method being requested
* @param param - Optional parameters for the request
* @param resolve - Promise resolution callback
* @param reject - Promise rejection callback
*/
async function adminAuthFlow(adminInterface, keyName, remotePubkey, method, param, resolve, reject) {
const requestedPerm = await adminInterface.requestPermission(keyName, remotePubkey, method, param);
@ -50,6 +68,16 @@ async function adminAuthFlow(adminInterface, keyName, remotePubkey, method, para
}
}
/**
* Creates a database record for the authorization request
*
* @param keyName - Optional identifier for the key being authorized
* @param requestId - Unique identifier for this authorization request
* @param remotePubkey - The public key of the remote party
* @param method - The method being requested
* @param param - Optional parameters for the request
* @returns The created request record
*/
async function createRecord(
keyName: string | undefined,
requestId: string,
@ -82,6 +110,17 @@ async function createRecord(
return request;
}
/**
* Handles the authorization flow when using a web-based approval process
*
* @param baseUrl - Base URL for the authorization endpoint
* @param admin - The admin interface instance
* @param remotePubkey - The public key of the remote party
* @param requestId - Unique identifier for this authorization request
* @param request - The request record from the database
* @param resolve - Promise resolution callback
* @param reject - Promise rejection callback
*/
export function urlAuthFlow(
baseUrl: string,
admin: AdminInterface,
@ -121,6 +160,13 @@ export function urlAuthFlow(
}, 100);
}
/**
* Generates the URL for a pending authorization request
*
* @param baseUrl - Base URL for the authorization endpoint
* @param request - The request record from the database
* @returns The complete URL for the authorization request
*/
function generatePendingAuthUrl(baseUrl: string, request: Request): string {
return [
baseUrl,

View File

@ -2,6 +2,17 @@ import NDK, { NDKNip46Backend, NDKPrivateKeySigner, Nip46PermitCallback } from '
import prisma from '../../db.js';
import type {FastifyInstance} from "fastify";
/**
* Backend Service Implementation
*
* This file implements the core backend functionality for the nsecBunker service.
* It handles:
* - Token validation and application
* - User authentication
* - Permission management
* - Integration with NDK (Nostr Development Kit)
*/
export class Backend extends NDKNip46Backend {
public baseUrl?: string;
public fastify: FastifyInstance;
@ -37,17 +48,29 @@ export class Backend extends NDKNip46Backend {
return tokenRecord;
}
/**
* Applies a token to a user, setting up their permissions
* Flow:
* 1. Validates the provided token
* 2. Creates or updates user record
* 3. Sets up basic connection permissions
* 4. Applies policy rules from token
*
* @param userPubkey - The user's public key
* @param token - The token to apply
*/
async applyToken(userPubkey: string, token: string): Promise<void> {
const tokenRecord = await this.validateToken(token);
const keyName = tokenRecord.keyName;
// Upsert the KeyUser with the given remotePubkey
// Create or update user record
const upsertedUser = await prisma.keyUser.upsert({
where: { unique_key_user: { keyName, userPubkey } },
update: { },
create: { keyName, userPubkey, description: tokenRecord.clientName },
});
// Set up basic connect permission
await prisma.signingCondition.create({
data: {
keyUserId: upsertedUser.id,
@ -56,7 +79,7 @@ export class Backend extends NDKNip46Backend {
}
});
// Go through the rules of this policy and apply them to the user
// Apply policy rules
for (const rule of tokenRecord!.policy!.rules) {
const signingConditionQuery: any = { method: rule.method };
@ -74,6 +97,7 @@ export class Backend extends NDKNip46Backend {
});
}
// Update token status
await prisma.token.update({
where: { id: tokenRecord.id },
data: {

View File

@ -1,7 +1,19 @@
import { NDKNip46Backend } from "@nostr-dev-kit/ndk";
import { IEventHandlingStrategy } from '@nostr-dev-kit/ndk';
/**
* Strategy for handling event publication requests in a Nostr NIP-46 backend.
* Implements the IEventHandlingStrategy interface for processing publish commands.
*/
export default class PublishEventHandlingStrategy implements IEventHandlingStrategy {
/**
* Handles the publication of a Nostr event.
* @param backend - The NIP-46 backend instance handling the request
* @param id - The request identifier
* @param remotePubkey - The public key of the remote client
* @param params - Array of parameters for the event creation
* @returns A promise that resolves to the stringified Nostr event or undefined if signing fails
*/
async handle(backend: NDKNip46Backend, id: string, remotePubkey: string, params: string[]): Promise<string|undefined> {
const event = await backend.signEvent(remotePubkey, params);
if (!event) return undefined;

View File

@ -1,6 +1,17 @@
import { NDKEvent, NostrEvent } from '@nostr-dev-kit/ndk';
import prisma from '../../../db.js';
/**
* Access Control List Implementation
*
* This module handles permission management and request validation.
* Features:
* - Method-specific permissions
* - Event kind filtering
* - Token-based access control
* - User permission management
*/
export async function checkIfPubkeyAllowed(
keyName: string,
remotePubkey: string,
@ -74,6 +85,12 @@ export type IAllowScope = {
kind?: number | 'all';
};
/**
* Converts a request into a signing condition query
* Handles special cases for different methods, especially sign_event
* @param method - The requested method
* @param payload - Optional payload or NostrEvent
*/
export function requestToSigningConditionQuery(method: IMethod, payload?: string | NostrEvent) {
const signingConditionQuery: any = { method };
@ -86,6 +103,12 @@ export function requestToSigningConditionQuery(method: IMethod, payload?: string
return signingConditionQuery;
}
/**
* Converts an allow scope into a signing condition query
* Used for creating new permissions
* @param method - The method to allow
* @param scope - Optional scope restrictions
*/
export function allowScopeToSigningConditionQuery(method: string, scope?: IAllowScope) {
const signingConditionQuery: any = { method };
@ -96,6 +119,16 @@ export function allowScopeToSigningConditionQuery(method: string, scope?: IAllow
return signingConditionQuery;
}
/**
* Grants permissions to a key for specific methods
* Creates or updates user record and signing conditions
* @param remotePubkey - The public key to grant permissions to
* @param keyName - The key name to grant permissions for
* @param method - The method to allow
* @param param - Optional parameters
* @param description - Optional user description
* @param allowScope - Optional scope restrictions
*/
export async function allowAllRequestsFromKey(
remotePubkey: string,
keyName: string,
@ -105,14 +138,14 @@ export async function allowAllRequestsFromKey(
allowScope?: IAllowScope,
): Promise<void> {
try {
// Upsert the KeyUser with the given remotePubkey
// Upsert the KeyUser record
const upsertedUser = await prisma.keyUser.upsert({
where: { unique_key_user: { keyName, userPubkey: remotePubkey } },
update: { },
create: { keyName, userPubkey: remotePubkey, description },
});
// Create a new SigningCondition for the given KeyUser and set allowed to true
// Create signing condition
const signingConditionQuery = allowScopeToSigningConditionQuery(method, allowScope);
await prisma.signingCondition.create({
data: {

View File

@ -1,9 +1,23 @@
/**
* Profile Management Module
*
* Handles user profile setup and management.
* Features:
* - Profile creation
* - Relay configuration
* - Profile encryption
*/
import NDK, { NDKEvent, NDKPrivateKeySigner, NostrEvent, type NDKUserProfile } from "@nostr-dev-kit/ndk";
import * as CryptoJS from 'crypto-js';
import createDebug from "debug";
const debug = createDebug("nsecbunker:profile");
/**
* Default relay list for new profiles
* Includes popular and reliable relays
*/
const explicitRelayUrls = [
'wss://purplepag.es',
'wss://relay.damus.io',

View File

@ -140,6 +140,18 @@ export default async function run(config: DaemonConfig) {
await daemon.start();
}
/**
* Daemon Service Runner
*
* This class initializes and manages the main nsecBunker daemon process.
* Responsibilities:
* - Configuration management
* - Key management and encryption
* - Web server setup
* - Admin interface initialization
* - Nostr relay connections
*/
class Daemon {
private config: DaemonConfig;
private activeKeys: Record<string, any>;
@ -147,35 +159,54 @@ class Daemon {
private ndk: NDK;
public fastify: FastifyInstance;
/**
* Initialize daemon with configuration
* Sets up:
* - Admin interface
* - Web server (Fastify)
* - NDK connection
*/
constructor(config: DaemonConfig) {
this.config = config;
this.activeKeys = config.keys;
this.adminInterface = new AdminInterface(config.admin, config.configFile);
// Set up admin interface methods
this.adminInterface.getKeys = getKeys(config);
this.adminInterface.getKeyUsers = getKeyUsers(config);
this.adminInterface.unlockKey = this.unlockKey.bind(this);
this.adminInterface.loadNsec = this.loadNsec.bind(this);
// Initialize web server
this.fastify = Fastify({ logger: true });
this.fastify.register(FastifyFormBody);
// Set up NDK connection
this.ndk = new NDK({
explicitRelayUrls: config.nostr.relays,
});
// Set up relay event handlers
this.ndk.pool.on('relay:connect', (r) => console.log(`✅ Connected to ${r.url}`) );
this.ndk.pool.on('relay:notice', (n, r) => { console.log(`👀 Notice from ${r.url}`, n); });
this.ndk.pool.on('relay:disconnect', (r) => {
console.log(`🚫 Disconnected from ${r.url}`);
});
}
/**
* Initializes the web authentication server if configured
* Sets up routes for:
* - Request authorization
* - Request processing
* - User registration
*/
async startWebAuth() {
if (!this.config.authPort) return;
const urlPrefix = new URL(this.config.baseUrl as string).pathname.replace(/\/+$/, '');
// Configure view engine
this.fastify.register(FastifyView, {
engine: {
handlebars: Handlebars,
@ -185,8 +216,10 @@ class Daemon {
}
});
// Start server
this.fastify.listen({ port: this.config.authPort, host: this.config.authHost });
// Set up routes
this.fastify.get('/requests/:id', authorizeRequestWebHandler);
this.fastify.post('/requests/:id', processRequestWebHandler);
this.fastify.post('/register/:id', processRegistrationWebHandler);

View File

@ -1,3 +1,13 @@
/**
* Authorization and Registration Handler
*
* This file handles web-based authorization flows including:
* - User authentication via cookies or username/password
* - Request validation and processing
* - New account registration
* - Permission management for nostr keys
*/
import prisma from "../../db";
import bcrypt from "bcrypt";
import { IAllowScope, allowAllRequestsFromKey } from "../lib/acl";
@ -7,7 +17,9 @@ import { validateRegistration } from "./registration-validations";
const debug = createDebug("nsecbunker:authorize");
/**
* TODO: This is still nto being used as no JWT is ever created
* Validates a JWT cookie for user authentication
* @param request - The incoming request object containing cookies
* @returns boolean - True if cookie is valid, false otherwise
*/
async function validateAuthCookie(request) {
const cookies = request.cookies || {};
@ -28,6 +40,12 @@ async function validateAuthCookie(request) {
return true;
}
/**
* Retrieves and validates a request record from the database
* @param request - Request containing the ID parameter
* @throws Error if request not found or already processed
* @returns The validated request record
*/
async function getAndValidateStateOfRequest(request) {
const record = await prisma.request.findUnique({
where: { id: request.params.id }
@ -40,9 +58,13 @@ async function getAndValidateStateOfRequest(request) {
return record;
}
/**
* Generates the view to authorize a request
* Web handler for displaying the authorization UI
* Routes to different templates based on request type:
* - create_account -> createAccount template
* - other methods -> authorizeRequest template
* @param request - The incoming HTTP request
* @param reply - The reply object for rendering views
*/
export async function authorizeRequestWebHandler(request, reply) {
try {
@ -71,7 +93,12 @@ export async function authorizeRequestWebHandler(request, reply) {
}
/**
* Validates + authenticates a request POSTed to the authorize endpoint
* Validates user authentication for a request
* Checks either cookie auth or username/password combo
* @param request - The incoming request with auth details
* @param record - The request record to validate against
* @returns The user record if valid
* @throws Error if validation fails
*/
export async function validateRequest(request, record) {
if (await validateAuthCookie(request)) {
@ -108,6 +135,16 @@ export async function validateRequest(request, record) {
return userRecord;
}
/**
* Processes an authorization request after validation
* Steps:
* 1. Validates request and user credentials
* 2. Marks request as allowed
* 3. Sets up permissions for the remote pubkey
* 4. Adds sign_event capability for connect requests
* @param request - The incoming HTTP request
* @param reply - The reply object for responses
*/
export async function processRequestWebHandler(request, reply) {
const record = await prisma.request.findUnique({
where: { id: request.params.id }
@ -159,6 +196,18 @@ export async function processRequestWebHandler(request, reply) {
return { ok: true, pubkey: userRecord.pubkey };
}
/**
* Handles new user registration requests
* Flow:
* 1. Validates registration data
* 2. Updates request with allowed status
* 3. Waits for key generation
* 4. Creates user record
* 5. Sets up permissions
* 6. Handles redirect if callback URL provided
* @param request - The registration request
* @param reply - The reply object for responses
*/
export async function processRegistrationWebHandler(request, reply) {
try {
const record = await getAndValidateStateOfRequest(request);
@ -250,6 +299,16 @@ export async function processRegistrationWebHandler(request, reply) {
}
}
/**
* Helper function to create a new user record
* Creates a hashed password and stores user details in database
* @param username - User's username
* @param domain - User's domain
* @param pubkey - User's public key
* @param email - User's email address
* @param password - User's plain text password
* @returns The created user record
*/
async function createUserRecord(
username: string,
domain: string,

View File

@ -1,25 +1,49 @@
/**
* Registration Validation Module
*
* Handles validation of new user registrations.
* Validates:
* - Username uniqueness
* - Password strength
* - Email format and uniqueness
*/
import prisma from "../../db";
/**
* Validates a new user registration request
* @param request - The registration request
* @param record - The existing request record
* @throws Error if validation fails
*/
export async function validateRegistration(request, record) {
// validate username uniqueness
const body = request.body;
const { username, domain, email, password } = body;
// Check username uniqueness
const userRecord = await prisma.user.findUnique({
where: { username, domain }
});
if (userRecord) throw new Error("Username already exists. If this is your account, please login instead.");
if (userRecord) {
throw new Error("Username already exists. If this is your account, please login instead.");
}
// validate password length
if (password.length < 8) throw new Error("Password is too short");
// Validate password strength
if (password.length < 8) {
throw new Error("Password is too short");
}
// validate email (if present)
// Validate email if provided
if (email) {
if (!email.includes("@")) throw new Error("Invalid email address");
if (!email.includes("@")) {
throw new Error("Invalid email address");
}
// validate email uniqueness (if one was provided)
// Check email uniqueness
const emailRecord = await prisma.user.findFirst({ where: { email } });
if (emailRecord) throw new Error("Email already exists");
if (emailRecord) {
throw new Error("Email already exists");
}
}
}

View File

@ -1,4 +1,22 @@
#!/usr/bin/env node
/**
* nsecBunker - A secure key management system for Nostr
*
* @description
* A CLI application that provides secure management of Nostr private keys (nsec).
* The bunker acts as a secure gateway, allowing controlled access to signing operations
* while keeping private keys encrypted at rest.
*
* @commands
* - setup: Initialize a new nsecBunker configuration file
* - start: Launch the nsecBunker service daemon
* - add: Securely store a new private key in the bunker
*
* @environment
* ADMIN_NPUBS - Comma-separated list of administrator public keys (npub)
*/
import 'websocket-polyfill';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
@ -7,13 +25,36 @@ import { setup } from './commands/setup.js';
import { addNsec } from './commands/add.js';
import { start } from './commands/start.js';
/**
* Administrator public keys can be specified via ADMIN_NPUBS environment variable
* Format: npub1,npub2,npub3
*/
const adminNpubs = process.env.ADMIN_NPUBS ? process.env.ADMIN_NPUBS.split(',') : [];
const argv = yargs(hideBin(process.argv))
/**
* Setup Command
* Initializes a new nsecBunker configuration with secure defaults
*
* Generated config includes:
* - admin.key: Auto-generated admin authentication key
* - nostr.relays: List of default Nostr relays
* - database: URI for persistent storage
* - logs: Path for application logs
*/
.command('setup', 'Setup nsecBunker', {}, (argv) => {
setup(argv.config as string);
})
/**
* Start Command
* Launches the nsecBunker service with specified configuration
*
* @options
* --verbose, -v: Enable detailed logging output
* --key <name>: Enable specific named keys (can specify multiple)
* --admin, -a <npub>: Additional admin public keys (can specify multiple)
*/
.command('start', 'Start nsecBunker', (yargs) => {
yargs
.option('verbose', {
@ -35,13 +76,34 @@ const argv = yargs(hideBin(process.argv))
});
}, (argv) => {
start({
keys: argv.key as string[],
verbose: argv.verbose as boolean,
config: argv.config as string,
adminNpubs: [...new Set([...((argv.admin||[]) as string[]), ...adminNpubs])]
keys: argv.key as string[], // List of specific keys to enable
verbose: argv.verbose as boolean, // Verbose logging flag
config: argv.config as string, // Configuration file path
adminNpubs: [...new Set([...((argv.admin||[]) as string[]), ...adminNpubs])] // Deduplicated admin NPUBs
});
})
/**
* Add Command
* Securely stores a new private key in the bunker
*
* Storage format in config:
* ```json
* {
* "keys": {
* "<keyId>": {
* "iv": "initialization vector",
* "data": "encrypted key data",
* // or
* "key": "unencrypted key (if encryption disabled)"
* }
* }
* }
* ```
*
* @options
* --name, -n: Unique identifier for the key (required)
*/
.command('add', 'Add an nsec', (yargs) => {
yargs
.option('name', {
@ -57,6 +119,7 @@ const argv = yargs(hideBin(process.argv))
});
})
// Global options available to all commands
.options({
'config': {
alias: 'c',

View File

@ -1,18 +1,36 @@
/**
* Direct Message Utility
*
* Handles sending encrypted direct messages to users via Nostr.
* Supports both npub and NDKUser recipients.
*/
import NDK, { NDKUser, NDKEvent, NostrEvent } from "@nostr-dev-kit/ndk";
/**
* Sends an encrypted direct message to a user
* @param ndk - NDK instance for sending messages
* @param recipient - Target user (npub string or NDKUser)
* @param content - Message content to send
* @returns The sent NDKEvent
*/
export async function dmUser(ndk: NDK, recipient: NDKUser | string, content: string): Promise<NDKEvent> {
let targetUser;
// Convert string recipient to NDKUser if needed
if (typeof recipient === 'string') {
targetUser = new NDKUser({ npub: recipient });
} else if (recipient instanceof NDKUser) {
targetUser = recipient;
}
// Create and encrypt the event
const event = new NDKEvent(ndk, { kind: 4, content } as NostrEvent);
event.tag(targetUser);
await event.encrypt(targetUser);
await event.sign();
// Attempt to publish
try {
await event.publish();
} catch (e) {