12 changed files with 444 additions and 53 deletions
@ -0,0 +1,98 @@
@@ -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); |
||||
} |
||||
}); |
||||
} |
||||
@ -0,0 +1,174 @@
@@ -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…
Reference in new issue