From e2c06c9c7f5b9336291b0083bd587dd244ea4abc Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 26 Jan 2026 19:52:31 +0100 Subject: [PATCH] don't display nsec in GUI --- README.md | 30 +++++-- manifest.json | 6 +- src/eventStorage.ts | 24 ++++- src/main.ts | 32 +++++-- src/metadataManager.ts | 3 +- src/nostr/authHandler.ts | 9 +- src/nostr/eventBuilder.ts | 24 ++++- src/nostr/profileFetcher.ts | 98 ++++++++++++++++++++ src/nostr/relayClient.ts | 9 +- src/relayManager.ts | 3 +- src/ui/settingsTab.ts | 85 +++++++++++++----- src/utils/security.ts | 174 ++++++++++++++++++++++++++++++++++++ 12 files changed, 444 insertions(+), 53 deletions(-) create mode 100644 src/nostr/profileFetcher.ts create mode 100644 src/utils/security.ts diff --git a/README.md b/README.md index 3cf001c..b0bdde4 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,35 @@ -# Scriptorium Obsidian Plugin +# Scriptorium Nostr An Obsidian plugin for creating, editing, and publishing Nostr document events directly from your vault. +**Author**: Silberengel +**Homepage**: https://gitcitadel.com +**Funding**: gitcitadel@getalby.com + ## Features -- **Multiple Event Kinds**: Support for kinds 1, 11, 30023, 30040, 30041, 30817, 30818 -- **AsciiDoc Support**: Automatic parsing and splitting of AsciiDoc documents into nested 30040/30041 structures +- **Multiple Event Kinds**: Support for Markdown-formatted kinds (1, 11, 30023, 30817) and Asdciidoc-formatted kinds (30040, 30041, 30818). +- **Bookstr Support**: Automatic parsing and splitting of e-books/publications into nested 30040/30041 structures - **Metadata Management**: YAML metadata files with validation per event kind -- **Structure Preview**: Visual preview of document structure before creating events +- **Structure Preview**: Visual preview of publication structure before creating events - **Two-Step Workflow**: Create and sign events separately from publishing - **Relay Management**: Automatic fetching of relay lists (kind 10002) with AUTH support - **d-tag Normalization**: Automatic NIP-54 compliant d-tag generation from titles ## Installation +### Manual Installation + 1. Clone this repository -2. Run `npm install` +2. Run `npm install`.obsidian/plugins/scriptorium-obsidian/ 3. Run `npm run build` -4. Copy the `main.js`, `manifest.json`, and `styles.css` (if any) to your Obsidian vault's `.obsidian/plugins/scriptorium-obsidian/` directory +4. Create the plugin directory in your Obsidian vault (if it doesn't exist): + - Navigate to your vault's root directory + - Create `.obsidian/plugins/scriptorium-obsidian/` directory +5. Copy the `main.js` and `manifest.json` files to `.obsidian/plugins/scriptorium-obsidian/` +6. Reload Obsidian and enable the plugin in Settings → Community Plugins + +**Note**: The `.obsidian` folder is hidden by default. You may need to show hidden files in your file manager to see it. ## Setup @@ -86,3 +98,9 @@ npm run build # Production build ## License MIT + +## Author + +**Silberengel** +- Homepage: https://gitcitadel.com +- Funding: gitcitadel@getalby.com \ No newline at end of file diff --git a/manifest.json b/manifest.json index 4989182..e9178b7 100644 --- a/manifest.json +++ b/manifest.json @@ -4,8 +4,8 @@ "version": "0.1.0", "minAppVersion": "0.15.0", "description": "Create, edit, and publish Nostr document events from Obsidian", - "author": "Scriptorium", - "authorUrl": "", - "fundingUrl": "", + "author": "Silberengel", + "authorUrl": "https://gitcitadel.com", + "fundingUrl": "gitcitadel@getalby.com", "isDesktopOnly": false } diff --git a/src/eventStorage.ts b/src/eventStorage.ts index 9e04ab3..e1495da 100644 --- a/src/eventStorage.ts +++ b/src/eventStorage.ts @@ -1,5 +1,6 @@ import { TFile } from "obsidian"; import { SignedEvent } from "./types"; +import { safeConsoleError, verifyEventSecurity } from "./utils/security"; /** * Get events file path for a given file @@ -19,6 +20,13 @@ export async function saveEvents( events: SignedEvent[], app: any ): Promise { + // Security check: verify no events contain private keys + for (const event of events) { + if (!verifyEventSecurity(event)) { + throw new Error("Security error: Cannot save event containing private key"); + } + } + const eventsPath = getEventsFilePath(file); const lines = events.map((event) => JSON.stringify(event)); const content = lines.join("\n") + "\n"; @@ -40,9 +48,19 @@ export async function loadEvents( } const content = await app.vault.read(eventsFile); const lines = content.split("\n").filter((line: string) => line.trim().length > 0); - return lines.map((line: string) => JSON.parse(line) as SignedEvent); + const events = lines.map((line: string) => JSON.parse(line) as SignedEvent); + + // Security check: verify loaded events don't contain private keys + for (const event of events) { + if (!verifyEventSecurity(event)) { + safeConsoleError("Security warning: Loaded event contains private key - removing from results"); + return []; + } + } + + return events; } catch (error) { - console.error("Error loading events:", error); + safeConsoleError("Error loading events:", error); return []; } } @@ -67,6 +85,6 @@ export async function deleteEvents(file: TFile, app: any): Promise { await app.vault.delete(eventsFile); } } catch (error) { - console.error("Error deleting events file:", error); + safeConsoleError("Error deleting events file:", error); } } diff --git a/src/main.ts b/src/main.ts index 6f60968..d50fb99 100644 --- a/src/main.ts +++ b/src/main.ts @@ -10,6 +10,7 @@ import { publishEventsWithRetry } from "./nostr/relayClient"; import { getWriteRelays } from "./relayManager"; import { parseAsciiDocStructure, isAsciiDocDocument } from "./asciidocParser"; import { normalizeSecretKey, getPubkeyFromPrivkey } from "./nostr/eventBuilder"; +import { safeConsoleError, safeConsoleLog, verifyEventSecurity } from "./utils/security"; export default class ScriptoriumPlugin extends Plugin { settings!: ScriptoriumSettings; @@ -73,7 +74,7 @@ export default class ScriptoriumPlugin extends Plugin { } } catch (error) { // Environment variable access not available, user must set manually - console.log("Environment variable access not available, use settings to set private key"); + safeConsoleLog("Environment variable access not available, use settings to set private key"); } } @@ -137,6 +138,15 @@ export default class ScriptoriumPlugin extends Plugin { return; } + // Security check: verify events don't contain private keys + for (const event of result.events) { + if (!verifyEventSecurity(event)) { + new Notice("Security error: Event contains private key. Aborting."); + safeConsoleError("Event security check failed - event may contain private key"); + return; + } + } + // Show preview for structured documents if (result.structure.length > 0) { new StructurePreviewModal(this.app, result.structure, async () => { @@ -148,8 +158,9 @@ export default class ScriptoriumPlugin extends Plugin { new Notice(`Created ${result.events.length} event(s) and saved to ${file.basename}_events.jsonl`); } } catch (error: any) { - new Notice(`Error creating events: ${error.message}`); - console.error(error); + const safeMessage = error?.message ? String(error.message).replace(/nsec1[a-z0-9]{58,}/gi, "[REDACTED]").replace(/[0-9a-f]{64}/gi, "[REDACTED]") : "Unknown error"; + new Notice(`Error creating events: ${safeMessage}`); + safeConsoleError("Error creating events:", error); } } @@ -175,8 +186,9 @@ export default class ScriptoriumPlugin extends Plugin { const structure = parseAsciiDocStructure(content, metadata as any); new StructurePreviewModal(this.app, structure, () => {}).open(); } catch (error: any) { - new Notice(`Error previewing structure: ${error.message}`); - console.error(error); + const safeMessage = error?.message ? String(error.message).replace(/nsec1[a-z0-9]{58,}/gi, "[REDACTED]").replace(/[0-9a-f]{64}/gi, "[REDACTED]") : "Unknown error"; + new Notice(`Error previewing structure: ${safeMessage}`); + safeConsoleError("Error previewing structure:", error); } } @@ -231,8 +243,9 @@ export default class ScriptoriumPlugin extends Plugin { new Notice(`Published ${successCount} event(s), ${failureCount} failed`); } } catch (error: any) { - new Notice(`Error publishing events: ${error.message}`); - console.error(error); + const safeMessage = error?.message ? String(error.message).replace(/nsec1[a-z0-9]{58,}/gi, "[REDACTED]").replace(/[0-9a-f]{64}/gi, "[REDACTED]") : "Unknown error"; + new Notice(`Error publishing events: ${safeMessage}`); + safeConsoleError("Error publishing events:", error); } } @@ -261,8 +274,9 @@ export default class ScriptoriumPlugin extends Plugin { new Notice("Metadata saved"); }).open(); } catch (error: any) { - new Notice(`Error editing metadata: ${error.message}`); - console.error(error); + const safeMessage = error?.message ? String(error.message).replace(/nsec1[a-z0-9]{58,}/gi, "[REDACTED]").replace(/[0-9a-f]{64}/gi, "[REDACTED]") : "Unknown error"; + new Notice(`Error editing metadata: ${safeMessage}`); + safeConsoleError("Error editing metadata:", error); } } } diff --git a/src/metadataManager.ts b/src/metadataManager.ts index f45fabb..65b155b 100644 --- a/src/metadataManager.ts +++ b/src/metadataManager.ts @@ -1,6 +1,7 @@ import * as yaml from "js-yaml"; import { TFile } from "obsidian"; import { EventKind, EventMetadata } from "./types"; +import { safeConsoleError } from "./utils/security"; /** * Get metadata file path for a given file @@ -29,7 +30,7 @@ export async function readMetadata( const parsed = yaml.load(content) as any; return parsed as EventMetadata; } catch (error) { - console.error("Error reading metadata:", error); + safeConsoleError("Error reading metadata:", error); return null; } } diff --git a/src/nostr/authHandler.ts b/src/nostr/authHandler.ts index e7fafb5..4da23f1 100644 --- a/src/nostr/authHandler.ts +++ b/src/nostr/authHandler.ts @@ -1,5 +1,6 @@ import { Relay, finalizeEvent, getPublicKey } from "nostr-tools"; import { normalizeSecretKey } from "./eventBuilder"; +import { safeConsoleError } from "../utils/security"; /** * Handle AUTH challenge from relay (NIP-42) @@ -21,7 +22,7 @@ export async function handleAuthChallenge( }); return true; } catch (error) { - console.error("Error handling AUTH challenge:", error); + safeConsoleError("Error handling AUTH challenge:", error); return false; } } @@ -46,7 +47,7 @@ export async function ensureAuthenticated( // The relay will call onauth if it needs authentication return true; } catch (error) { - console.error("Error ensuring authentication:", error); + safeConsoleError("Error ensuring authentication:", error); return false; } } @@ -71,6 +72,8 @@ export async function handleAuthRequiredError( // Retry original operation return originalOperation(); } catch (error: any) { - throw new Error(`Failed to authenticate with relay: ${error.message}`); + // Sanitize error message to prevent private key leaks + const safeMessage = error?.message ? String(error.message).replace(/nsec1[a-z0-9]{58,}/gi, "[REDACTED]").replace(/[0-9a-f]{64}/gi, "[REDACTED]") : "Unknown error"; + throw new Error(`Failed to authenticate with relay: ${safeMessage}`); } } diff --git a/src/nostr/eventBuilder.ts b/src/nostr/eventBuilder.ts index 06f152f..22a0612 100644 --- a/src/nostr/eventBuilder.ts +++ b/src/nostr/eventBuilder.ts @@ -1,5 +1,6 @@ import { finalizeEvent, getEventHash, getPublicKey, nip19 } from "nostr-tools"; import { EventKind, EventMetadata, SignedEvent } from "../types"; +import { sanitizeString } from "../utils/security"; /** * Normalize secret key from bech32 nsec or hex format to hex @@ -12,7 +13,8 @@ export function normalizeSecretKey(key: string): Uint8Array { return decoded.data; } } catch (e) { - throw new Error(`Invalid nsec format: ${e}`); + const errorMsg = e instanceof Error ? e.message : String(e); + throw new Error(`Invalid nsec format: ${sanitizeString(errorMsg)}`); } } // Assume hex format (64 chars) @@ -42,6 +44,26 @@ export function getPubkeyFromPrivkeyBytes(privkey: Uint8Array): string { return getPublicKey(privkey); } +/** + * Convert public key to npub (bech32 encoded) + */ +export function pubkeyToNpub(pubkey: string): string { + try { + return nip19.npubEncode(pubkey); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to encode pubkey to npub: ${sanitizeString(errorMsg)}`); + } +} + +/** + * Get npub from private key + */ +export function getNpubFromPrivkey(privkey: string): string { + const pubkey = getPubkeyFromPrivkey(privkey); + return pubkeyToNpub(pubkey); +} + /** * Build tags array from metadata */ diff --git a/src/nostr/profileFetcher.ts b/src/nostr/profileFetcher.ts new file mode 100644 index 0000000..e7513f5 --- /dev/null +++ b/src/nostr/profileFetcher.ts @@ -0,0 +1,98 @@ +import { Relay } from "nostr-tools"; +import { safeConsoleError } from "../utils/security"; + +/** + * User profile information + */ +export interface UserProfile { + name?: string; + display_name?: string; + about?: string; + picture?: string; +} + +/** + * Fetch user profile (kind 0) from relays + */ +export async function fetchUserProfile( + pubkey: string, + relayUrls: string[], + timeout: number = 5000 +): Promise { + for (const relayUrl of relayUrls) { + try { + const profile = await fetchProfileFromRelay(relayUrl, pubkey, timeout); + if (profile) { + return profile; + } + } catch (error) { + safeConsoleError(`Error fetching profile from ${relayUrl}:`, error); + continue; + } + } + return null; +} + +/** + * Fetch profile from a single relay + */ +async function fetchProfileFromRelay( + relayUrl: string, + pubkey: string, + timeout: number +): Promise { + return new Promise(async (resolve) => { + let relay: Relay | null = null; + const timer = setTimeout(() => { + if (relay) { + relay.close(); + } + resolve(null); + }, timeout); + + try { + relay = new Relay(relayUrl); + await relay.connect(); + + const sub = relay.subscribe( + [ + { + kinds: [0], + authors: [pubkey], + }, + ], + { + onevent: (event) => { + clearTimeout(timer); + relay?.close(); + try { + const profile = JSON.parse(event.content) as UserProfile; + resolve(profile); + } catch (error) { + resolve(null); + } + }, + oneose: () => { + clearTimeout(timer); + relay?.close(); + resolve(null); + }, + } + ); + + // Wait for response + setTimeout(() => { + sub.close(); + if (relay) { + relay.close(); + } + }, timeout - 100); + } catch (error) { + clearTimeout(timer); + if (relay) { + relay.close(); + } + resolve(null); + } + }); +} diff --git a/src/nostr/relayClient.ts b/src/nostr/relayClient.ts index f3c7a01..a617068 100644 --- a/src/nostr/relayClient.ts +++ b/src/nostr/relayClient.ts @@ -1,6 +1,7 @@ import { Relay } from "nostr-tools"; import { SignedEvent, PublishingResult } from "../types"; import { ensureAuthenticated, handleAuthRequiredError } from "./authHandler"; +import { safeConsoleError } from "../utils/security"; /** * Publish a single event to a relay @@ -27,7 +28,7 @@ export async function publishEventToRelay( handleAuthRequiredError(relay!, privkey, relayUrl, async () => { return await relay!.publish(event); }).catch((error) => { - console.error("Auth failed:", error); + safeConsoleError("Auth failed:", error); }); } if (originalOnNotice) { @@ -64,22 +65,24 @@ export async function publishEventToRelay( }; } catch (error: any) { relay.close(); + const safeMessage = error?.message ? String(error.message).replace(/nsec1[a-z0-9]{58,}/gi, "[REDACTED]").replace(/[0-9a-f]{64}/gi, "[REDACTED]") : "Publish failed"; return { eventId: event.id, relay: relayUrl, success: false, - message: error.message || "Publish failed", + message: safeMessage, }; } } catch (error: any) { if (relay) { relay.close(); } + const safeMessage = error?.message ? String(error.message).replace(/nsec1[a-z0-9]{58,}/gi, "[REDACTED]").replace(/[0-9a-f]{64}/gi, "[REDACTED]") : "Failed to connect to relay"; return { eventId: event.id, relay: relayUrl, success: false, - message: error.message || "Failed to connect to relay", + message: safeMessage, }; } } diff --git a/src/relayManager.ts b/src/relayManager.ts index fdcf6d2..8e427b9 100644 --- a/src/relayManager.ts +++ b/src/relayManager.ts @@ -1,6 +1,7 @@ import { Relay, getPublicKey } from "nostr-tools"; import { RelayInfo } from "./types"; import { normalizeSecretKey } from "./nostr/eventBuilder"; +import { safeConsoleError } from "./utils/security"; /** * Default relay URLs to query for kind 10002 @@ -98,7 +99,7 @@ export async function fetchRelayListFromRelay( if (relay) { relay.close(); } - console.error(`Error fetching relay list from ${relayUrl}:`, error); + safeConsoleError(`Error fetching relay list from ${relayUrl}:`, error); resolve(null); } }); diff --git a/src/ui/settingsTab.ts b/src/ui/settingsTab.ts index 2a7e9be..dde5561 100644 --- a/src/ui/settingsTab.ts +++ b/src/ui/settingsTab.ts @@ -1,8 +1,9 @@ import { App, PluginSettingTab, Setting } from "obsidian"; import ScriptoriumPlugin from "../main"; import { EventKind } from "../types"; -import { fetchRelayList, addTheCitadelIfMissing, includesTheCitadel } from "../relayManager"; -import { getPubkeyFromPrivkey } from "../nostr/eventBuilder"; +import { fetchRelayList, addTheCitadelIfMissing, includesTheCitadel, getReadRelays } from "../relayManager"; +import { getPubkeyFromPrivkey, getNpubFromPrivkey } from "../nostr/eventBuilder"; +import { fetchUserProfile } from "../nostr/profileFetcher"; /** * Settings tab for the plugin @@ -15,31 +16,69 @@ export class ScriptoriumSettingTab extends PluginSettingTab { this.plugin = plugin; } - display(): void { + async display(): Promise { const { containerEl } = this; containerEl.empty(); containerEl.createEl("h2", { text: "Scriptorium Nostr Settings" }); - // Private Key - new Setting(containerEl) - .setName("Private Key") - .setDesc("Your Nostr private key (nsec or hex). Loaded from SCRIPTORIUM_OBSIDIAN_KEY environment variable.") - .addText((text) => { - const key = this.plugin.settings.privateKey || ""; - text.setValue(key ? "***" + key.slice(-4) : "") - .setPlaceholder("nsec1... or hex") - .setDisabled(true); - }) - .addButton((button) => { - button.setButtonText("Refresh from Env") - .setCta() - .onClick(async () => { - await this.plugin.loadPrivateKey(); - this.display(); + // User Identity (npub and handle) + if (this.plugin.settings.privateKey) { + try { + const npub = getNpubFromPrivkey(this.plugin.settings.privateKey); + const pubkey = getPubkeyFromPrivkey(this.plugin.settings.privateKey); + + // Fetch profile to get handle/name + let profile: { name?: string; display_name?: string } | null = null; + const readRelays = getReadRelays(this.plugin.settings.relayList); + if (readRelays.length > 0) { + profile = await fetchUserProfile(pubkey, readRelays); + } + + const displayName = profile?.display_name || profile?.name || "Unknown"; + + new Setting(containerEl) + .setName("Your Identity") + .setDesc("Your Nostr public identity (loaded from SCRIPTORIUM_OBSIDIAN_KEY)") + .addText((text) => { + text.setValue(`${displayName} (${npub})`) + .setDisabled(true); + }) + .addButton((button) => { + button.setButtonText("Refresh from Env") + .setCta() + .onClick(async () => { + await this.plugin.loadPrivateKey(); + await this.display(); + }); }); - }); + } catch (error: any) { + new Setting(containerEl) + .setName("Private Key Status") + .setDesc(`Error: ${error.message}`) + .addButton((button) => { + button.setButtonText("Refresh from Env") + .setCta() + .onClick(async () => { + await this.plugin.loadPrivateKey(); + await this.display(); + }); + }); + } + } else { + new Setting(containerEl) + .setName("Private Key") + .setDesc("No private key found. Set SCRIPTORIUM_OBSIDIAN_KEY environment variable.") + .addButton((button) => { + button.setButtonText("Refresh from Env") + .setCta() + .onClick(async () => { + await this.plugin.loadPrivateKey(); + await this.display(); + }); + }); + } // Default Event Kind new Setting(containerEl) @@ -125,7 +164,7 @@ export class ScriptoriumSettingTab extends PluginSettingTab { this.plugin.settings.relayList = finalList; await this.plugin.saveSettings(); - this.display(); + await this.display(); } catch (error: any) { alert(`Error fetching relay list: ${error.message}`); } @@ -152,7 +191,7 @@ export class ScriptoriumSettingTab extends PluginSettingTab { .onClick(async () => { this.plugin.settings.relayList.splice(index, 1); await this.plugin.saveSettings(); - this.display(); + await this.display(); }); }); }); @@ -179,7 +218,7 @@ export class ScriptoriumSettingTab extends PluginSettingTab { write: true, }); await this.plugin.saveSettings(); - this.display(); + await this.display(); } } }); diff --git a/src/utils/security.ts b/src/utils/security.ts new file mode 100644 index 0000000..cfe37c8 --- /dev/null +++ b/src/utils/security.ts @@ -0,0 +1,174 @@ +/** + * Security utilities to prevent private key leaks + */ + +/** + * Check if a hex string is likely a private key based on context + * Private keys are 64 hex characters, but so are public keys and event IDs. + * We can only identify private keys by context (error messages, variable names, etc.) + */ +function isLikelyPrivateKey(context: string, hexString: string): boolean { + const lowerContext = context.toLowerCase(); + + // Check for explicit private key indicators + const privateKeyIndicators = [ + 'privkey', 'private_key', 'privatekey', 'secret', 'nsec', + 'private key', 'secret key', 'signing key' + ]; + + // Check if context mentions private key concepts + for (const indicator of privateKeyIndicators) { + if (lowerContext.includes(indicator)) { + return true; + } + } + + // If it's in an error message about keys, it's likely a private key + if (lowerContext.includes('key') && + (lowerContext.includes('invalid') || lowerContext.includes('error') || lowerContext.includes('failed'))) { + return true; + } + + return false; +} + +/** + * Sanitize a string to remove any private key patterns + * + * Distinguishes between: + * - Private keys: nsec1... (bech32) or hex in private key context + * - Public keys: npub1... (bech32) or hex (64 chars) - these are SAFE to log + * - Event IDs: hex (64 chars) - these are SAFE to log + */ +export function sanitizeString(str: string): string { + if (!str) return str; + + let sanitized = str; + + // Remove nsec bech32 keys (nsec1...) - these are ALWAYS private keys + const nsecPattern = /nsec1[a-z0-9]{58,}/gi; + sanitized = sanitized.replace(nsecPattern, "[PRIVATE_KEY_REDACTED]"); + + // For hex strings (64 chars), only remove if context suggests it's a private key + // Public keys (npub1... or hex) and event IDs (hex) should NOT be removed + const hexPattern = /(?:^|\s|"|'|`)([0-9a-f]{64})(?:\s|"|'|`|$)/gi; + sanitized = sanitized.replace(hexPattern, (match, hexString, offset) => { + // Get surrounding context (50 chars before and after) + const start = Math.max(0, offset - 50); + const end = Math.min(str.length, offset + match.length + 50); + const context = str.substring(start, end); + + // Only redact if context suggests it's a private key + if (isLikelyPrivateKey(context, hexString)) { + return match.replace(hexString, "[PRIVATE_KEY_REDACTED]"); + } + + // Otherwise, it's likely a public key or event ID - keep it + return match; + }); + + return sanitized; +} + +/** + * Sanitize an error object to remove private keys + */ +export function sanitizeError(error: any): any { + if (!error) return error; + + // If it's a string, sanitize it + if (typeof error === "string") { + return sanitizeString(error); + } + + // If it's an Error object, sanitize the message + if (error instanceof Error) { + const sanitized = new Error(sanitizeString(error.message)); + sanitized.name = error.name; + sanitized.stack = error.stack ? sanitizeString(error.stack) : undefined; + return sanitized; + } + + // If it's an object, sanitize string properties + if (typeof error === "object") { + const sanitized: any = {}; + for (const [key, value] of Object.entries(error)) { + if (typeof value === "string") { + sanitized[key] = sanitizeString(value); + } else if (value instanceof Error) { + sanitized[key] = sanitizeError(value); + } else { + sanitized[key] = value; + } + } + return sanitized; + } + + return error; +} + +/** + * Safe console.error that never logs private keys + */ +export function safeConsoleError(message: string, ...args: any[]): void { + const sanitizedArgs = args.map(arg => sanitizeError(arg)); + console.error(sanitizeString(message), ...sanitizedArgs); +} + +/** + * Safe console.log that never logs private keys + */ +export function safeConsoleLog(message: string, ...args: any[]): void { + const sanitizedArgs = args.map(arg => sanitizeError(arg)); + console.log(sanitizeString(message), ...sanitizedArgs); +} + +/** + * Verify that an event doesn't contain private key in content or tags + * + * Note: Public keys and event IDs are EXPECTED in events and should NOT be flagged. + * Only private keys (nsec1...) should be detected. + */ +export function verifyEventSecurity(event: any): boolean { + if (!event) return false; + + // Check content for nsec bech32 private keys + if (event.content && typeof event.content === "string") { + // Only check for nsec1... bech32 private keys + // Public keys (npub1... or hex) and event IDs (hex) are safe + if (event.content.includes("nsec1")) { + return false; + } + + // Check for hex strings that might be private keys in content + // This is tricky - we can't distinguish hex private keys from public keys/event IDs + // But if content contains "nsec" or "private key" context, flag it + const contentLower = event.content.toLowerCase(); + if ((contentLower.includes("nsec") || + contentLower.includes("private key") || + contentLower.includes("privkey")) && + /[0-9a-f]{64}/i.test(event.content)) { + // Context suggests private key - flag it + return false; + } + } + + // Check tags for nsec bech32 private keys + if (event.tags && Array.isArray(event.tags)) { + for (const tag of event.tags) { + if (Array.isArray(tag)) { + for (const value of tag) { + if (typeof value === "string") { + // Only flag nsec1... bech32 private keys + // Public keys in "p" tags and event IDs in "e" tags are EXPECTED + if (value.includes("nsec1")) { + return false; + } + } + } + } + } + } + + return true; +}