Browse Source

don't display nsec in GUI

master
Silberengel 1 week ago
parent
commit
e2c06c9c7f
  1. 30
      README.md
  2. 6
      manifest.json
  3. 24
      src/eventStorage.ts
  4. 32
      src/main.ts
  5. 3
      src/metadataManager.ts
  6. 9
      src/nostr/authHandler.ts
  7. 24
      src/nostr/eventBuilder.ts
  8. 98
      src/nostr/profileFetcher.ts
  9. 9
      src/nostr/relayClient.ts
  10. 3
      src/relayManager.ts
  11. 65
      src/ui/settingsTab.ts
  12. 174
      src/utils/security.ts

30
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. 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 ## Features
- **Multiple Event Kinds**: Support for kinds 1, 11, 30023, 30040, 30041, 30817, 30818 - **Multiple Event Kinds**: Support for Markdown-formatted kinds (1, 11, 30023, 30817) and Asdciidoc-formatted kinds (30040, 30041, 30818).
- **AsciiDoc Support**: Automatic parsing and splitting of AsciiDoc documents into nested 30040/30041 structures - **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 - **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 - **Two-Step Workflow**: Create and sign events separately from publishing
- **Relay Management**: Automatic fetching of relay lists (kind 10002) with AUTH support - **Relay Management**: Automatic fetching of relay lists (kind 10002) with AUTH support
- **d-tag Normalization**: Automatic NIP-54 compliant d-tag generation from titles - **d-tag Normalization**: Automatic NIP-54 compliant d-tag generation from titles
## Installation ## Installation
### Manual Installation
1. Clone this repository 1. Clone this repository
2. Run `npm install` 2. Run `npm install`.obsidian/plugins/scriptorium-obsidian/
3. Run `npm run build` 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 ## Setup
@ -86,3 +98,9 @@ npm run build # Production build
## License ## License
MIT MIT
## Author
**Silberengel**
- Homepage: https://gitcitadel.com
- Funding: gitcitadel@getalby.com

6
manifest.json

@ -4,8 +4,8 @@
"version": "0.1.0", "version": "0.1.0",
"minAppVersion": "0.15.0", "minAppVersion": "0.15.0",
"description": "Create, edit, and publish Nostr document events from Obsidian", "description": "Create, edit, and publish Nostr document events from Obsidian",
"author": "Scriptorium", "author": "Silberengel",
"authorUrl": "", "authorUrl": "https://gitcitadel.com",
"fundingUrl": "", "fundingUrl": "gitcitadel@getalby.com",
"isDesktopOnly": false "isDesktopOnly": false
} }

24
src/eventStorage.ts

@ -1,5 +1,6 @@
import { TFile } from "obsidian"; import { TFile } from "obsidian";
import { SignedEvent } from "./types"; import { SignedEvent } from "./types";
import { safeConsoleError, verifyEventSecurity } from "./utils/security";
/** /**
* Get events file path for a given file * Get events file path for a given file
@ -19,6 +20,13 @@ export async function saveEvents(
events: SignedEvent[], events: SignedEvent[],
app: any app: any
): Promise<void> { ): Promise<void> {
// 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 eventsPath = getEventsFilePath(file);
const lines = events.map((event) => JSON.stringify(event)); const lines = events.map((event) => JSON.stringify(event));
const content = lines.join("\n") + "\n"; const content = lines.join("\n") + "\n";
@ -40,9 +48,19 @@ export async function loadEvents(
} }
const content = await app.vault.read(eventsFile); const content = await app.vault.read(eventsFile);
const lines = content.split("\n").filter((line: string) => line.trim().length > 0); 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) { } catch (error) {
console.error("Error loading events:", error); safeConsoleError("Error loading events:", error);
return []; return [];
} }
} }
@ -67,6 +85,6 @@ export async function deleteEvents(file: TFile, app: any): Promise<void> {
await app.vault.delete(eventsFile); await app.vault.delete(eventsFile);
} }
} catch (error) { } catch (error) {
console.error("Error deleting events file:", error); safeConsoleError("Error deleting events file:", error);
} }
} }

32
src/main.ts

@ -10,6 +10,7 @@ import { publishEventsWithRetry } from "./nostr/relayClient";
import { getWriteRelays } from "./relayManager"; import { getWriteRelays } from "./relayManager";
import { parseAsciiDocStructure, isAsciiDocDocument } from "./asciidocParser"; import { parseAsciiDocStructure, isAsciiDocDocument } from "./asciidocParser";
import { normalizeSecretKey, getPubkeyFromPrivkey } from "./nostr/eventBuilder"; import { normalizeSecretKey, getPubkeyFromPrivkey } from "./nostr/eventBuilder";
import { safeConsoleError, safeConsoleLog, verifyEventSecurity } from "./utils/security";
export default class ScriptoriumPlugin extends Plugin { export default class ScriptoriumPlugin extends Plugin {
settings!: ScriptoriumSettings; settings!: ScriptoriumSettings;
@ -73,7 +74,7 @@ export default class ScriptoriumPlugin extends Plugin {
} }
} catch (error) { } catch (error) {
// Environment variable access not available, user must set manually // 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; 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 // Show preview for structured documents
if (result.structure.length > 0) { if (result.structure.length > 0) {
new StructurePreviewModal(this.app, result.structure, async () => { 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`); new Notice(`Created ${result.events.length} event(s) and saved to ${file.basename}_events.jsonl`);
} }
} catch (error: any) { } catch (error: any) {
new Notice(`Error creating events: ${error.message}`); const safeMessage = error?.message ? String(error.message).replace(/nsec1[a-z0-9]{58,}/gi, "[REDACTED]").replace(/[0-9a-f]{64}/gi, "[REDACTED]") : "Unknown error";
console.error(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); const structure = parseAsciiDocStructure(content, metadata as any);
new StructurePreviewModal(this.app, structure, () => {}).open(); new StructurePreviewModal(this.app, structure, () => {}).open();
} catch (error: any) { } catch (error: any) {
new Notice(`Error previewing structure: ${error.message}`); const safeMessage = error?.message ? String(error.message).replace(/nsec1[a-z0-9]{58,}/gi, "[REDACTED]").replace(/[0-9a-f]{64}/gi, "[REDACTED]") : "Unknown error";
console.error(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`); new Notice(`Published ${successCount} event(s), ${failureCount} failed`);
} }
} catch (error: any) { } catch (error: any) {
new Notice(`Error publishing events: ${error.message}`); const safeMessage = error?.message ? String(error.message).replace(/nsec1[a-z0-9]{58,}/gi, "[REDACTED]").replace(/[0-9a-f]{64}/gi, "[REDACTED]") : "Unknown error";
console.error(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"); new Notice("Metadata saved");
}).open(); }).open();
} catch (error: any) { } catch (error: any) {
new Notice(`Error editing metadata: ${error.message}`); const safeMessage = error?.message ? String(error.message).replace(/nsec1[a-z0-9]{58,}/gi, "[REDACTED]").replace(/[0-9a-f]{64}/gi, "[REDACTED]") : "Unknown error";
console.error(error); new Notice(`Error editing metadata: ${safeMessage}`);
safeConsoleError("Error editing metadata:", error);
} }
} }
} }

3
src/metadataManager.ts

@ -1,6 +1,7 @@
import * as yaml from "js-yaml"; import * as yaml from "js-yaml";
import { TFile } from "obsidian"; import { TFile } from "obsidian";
import { EventKind, EventMetadata } from "./types"; import { EventKind, EventMetadata } from "./types";
import { safeConsoleError } from "./utils/security";
/** /**
* Get metadata file path for a given file * Get metadata file path for a given file
@ -29,7 +30,7 @@ export async function readMetadata(
const parsed = yaml.load(content) as any; const parsed = yaml.load(content) as any;
return parsed as EventMetadata; return parsed as EventMetadata;
} catch (error) { } catch (error) {
console.error("Error reading metadata:", error); safeConsoleError("Error reading metadata:", error);
return null; return null;
} }
} }

9
src/nostr/authHandler.ts

@ -1,5 +1,6 @@
import { Relay, finalizeEvent, getPublicKey } from "nostr-tools"; import { Relay, finalizeEvent, getPublicKey } from "nostr-tools";
import { normalizeSecretKey } from "./eventBuilder"; import { normalizeSecretKey } from "./eventBuilder";
import { safeConsoleError } from "../utils/security";
/** /**
* Handle AUTH challenge from relay (NIP-42) * Handle AUTH challenge from relay (NIP-42)
@ -21,7 +22,7 @@ export async function handleAuthChallenge(
}); });
return true; return true;
} catch (error) { } catch (error) {
console.error("Error handling AUTH challenge:", error); safeConsoleError("Error handling AUTH challenge:", error);
return false; return false;
} }
} }
@ -46,7 +47,7 @@ export async function ensureAuthenticated(
// The relay will call onauth if it needs authentication // The relay will call onauth if it needs authentication
return true; return true;
} catch (error) { } catch (error) {
console.error("Error ensuring authentication:", error); safeConsoleError("Error ensuring authentication:", error);
return false; return false;
} }
} }
@ -71,6 +72,8 @@ export async function handleAuthRequiredError(
// Retry original operation // Retry original operation
return originalOperation(); return originalOperation();
} catch (error: any) { } 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}`);
} }
} }

24
src/nostr/eventBuilder.ts

@ -1,5 +1,6 @@
import { finalizeEvent, getEventHash, getPublicKey, nip19 } from "nostr-tools"; import { finalizeEvent, getEventHash, getPublicKey, nip19 } from "nostr-tools";
import { EventKind, EventMetadata, SignedEvent } from "../types"; import { EventKind, EventMetadata, SignedEvent } from "../types";
import { sanitizeString } from "../utils/security";
/** /**
* Normalize secret key from bech32 nsec or hex format to hex * Normalize secret key from bech32 nsec or hex format to hex
@ -12,7 +13,8 @@ export function normalizeSecretKey(key: string): Uint8Array {
return decoded.data; return decoded.data;
} }
} catch (e) { } 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) // Assume hex format (64 chars)
@ -42,6 +44,26 @@ export function getPubkeyFromPrivkeyBytes(privkey: Uint8Array): string {
return getPublicKey(privkey); 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 * Build tags array from metadata
*/ */

98
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<UserProfile | null> {
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<UserProfile | null> {
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);
}
});
}

9
src/nostr/relayClient.ts

@ -1,6 +1,7 @@
import { Relay } from "nostr-tools"; import { Relay } from "nostr-tools";
import { SignedEvent, PublishingResult } from "../types"; import { SignedEvent, PublishingResult } from "../types";
import { ensureAuthenticated, handleAuthRequiredError } from "./authHandler"; import { ensureAuthenticated, handleAuthRequiredError } from "./authHandler";
import { safeConsoleError } from "../utils/security";
/** /**
* Publish a single event to a relay * Publish a single event to a relay
@ -27,7 +28,7 @@ export async function publishEventToRelay(
handleAuthRequiredError(relay!, privkey, relayUrl, async () => { handleAuthRequiredError(relay!, privkey, relayUrl, async () => {
return await relay!.publish(event); return await relay!.publish(event);
}).catch((error) => { }).catch((error) => {
console.error("Auth failed:", error); safeConsoleError("Auth failed:", error);
}); });
} }
if (originalOnNotice) { if (originalOnNotice) {
@ -64,22 +65,24 @@ export async function publishEventToRelay(
}; };
} catch (error: any) { } catch (error: any) {
relay.close(); 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 { return {
eventId: event.id, eventId: event.id,
relay: relayUrl, relay: relayUrl,
success: false, success: false,
message: error.message || "Publish failed", message: safeMessage,
}; };
} }
} catch (error: any) { } catch (error: any) {
if (relay) { if (relay) {
relay.close(); 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 { return {
eventId: event.id, eventId: event.id,
relay: relayUrl, relay: relayUrl,
success: false, success: false,
message: error.message || "Failed to connect to relay", message: safeMessage,
}; };
} }
} }

3
src/relayManager.ts

@ -1,6 +1,7 @@
import { Relay, getPublicKey } from "nostr-tools"; import { Relay, getPublicKey } from "nostr-tools";
import { RelayInfo } from "./types"; import { RelayInfo } from "./types";
import { normalizeSecretKey } from "./nostr/eventBuilder"; import { normalizeSecretKey } from "./nostr/eventBuilder";
import { safeConsoleError } from "./utils/security";
/** /**
* Default relay URLs to query for kind 10002 * Default relay URLs to query for kind 10002
@ -98,7 +99,7 @@ export async function fetchRelayListFromRelay(
if (relay) { if (relay) {
relay.close(); relay.close();
} }
console.error(`Error fetching relay list from ${relayUrl}:`, error); safeConsoleError(`Error fetching relay list from ${relayUrl}:`, error);
resolve(null); resolve(null);
} }
}); });

65
src/ui/settingsTab.ts

@ -1,8 +1,9 @@
import { App, PluginSettingTab, Setting } from "obsidian"; import { App, PluginSettingTab, Setting } from "obsidian";
import ScriptoriumPlugin from "../main"; import ScriptoriumPlugin from "../main";
import { EventKind } from "../types"; import { EventKind } from "../types";
import { fetchRelayList, addTheCitadelIfMissing, includesTheCitadel } from "../relayManager"; import { fetchRelayList, addTheCitadelIfMissing, includesTheCitadel, getReadRelays } from "../relayManager";
import { getPubkeyFromPrivkey } from "../nostr/eventBuilder"; import { getPubkeyFromPrivkey, getNpubFromPrivkey } from "../nostr/eventBuilder";
import { fetchUserProfile } from "../nostr/profileFetcher";
/** /**
* Settings tab for the plugin * Settings tab for the plugin
@ -15,21 +16,33 @@ export class ScriptoriumSettingTab extends PluginSettingTab {
this.plugin = plugin; this.plugin = plugin;
} }
display(): void { async display(): Promise<void> {
const { containerEl } = this; const { containerEl } = this;
containerEl.empty(); containerEl.empty();
containerEl.createEl("h2", { text: "Scriptorium Nostr Settings" }); containerEl.createEl("h2", { text: "Scriptorium Nostr Settings" });
// Private Key // 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) new Setting(containerEl)
.setName("Private Key") .setName("Your Identity")
.setDesc("Your Nostr private key (nsec or hex). Loaded from SCRIPTORIUM_OBSIDIAN_KEY environment variable.") .setDesc("Your Nostr public identity (loaded from SCRIPTORIUM_OBSIDIAN_KEY)")
.addText((text) => { .addText((text) => {
const key = this.plugin.settings.privateKey || ""; text.setValue(`${displayName} (${npub})`)
text.setValue(key ? "***" + key.slice(-4) : "")
.setPlaceholder("nsec1... or hex")
.setDisabled(true); .setDisabled(true);
}) })
.addButton((button) => { .addButton((button) => {
@ -37,9 +50,35 @@ export class ScriptoriumSettingTab extends PluginSettingTab {
.setCta() .setCta()
.onClick(async () => { .onClick(async () => {
await this.plugin.loadPrivateKey(); await this.plugin.loadPrivateKey();
this.display(); 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 // Default Event Kind
new Setting(containerEl) new Setting(containerEl)
@ -125,7 +164,7 @@ export class ScriptoriumSettingTab extends PluginSettingTab {
this.plugin.settings.relayList = finalList; this.plugin.settings.relayList = finalList;
await this.plugin.saveSettings(); await this.plugin.saveSettings();
this.display(); await this.display();
} catch (error: any) { } catch (error: any) {
alert(`Error fetching relay list: ${error.message}`); alert(`Error fetching relay list: ${error.message}`);
} }
@ -152,7 +191,7 @@ export class ScriptoriumSettingTab extends PluginSettingTab {
.onClick(async () => { .onClick(async () => {
this.plugin.settings.relayList.splice(index, 1); this.plugin.settings.relayList.splice(index, 1);
await this.plugin.saveSettings(); await this.plugin.saveSettings();
this.display(); await this.display();
}); });
}); });
}); });
@ -179,7 +218,7 @@ export class ScriptoriumSettingTab extends PluginSettingTab {
write: true, write: true,
}); });
await this.plugin.saveSettings(); await this.plugin.saveSettings();
this.display(); await this.display();
} }
} }
}); });

174
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;
}
Loading…
Cancel
Save