/** * @module nips/nip-04 * @description Implementation of NIP-04 (Encrypted Direct Messages) * @see https://github.com/nostr-protocol/nips/blob/master/04.md */ import { secp256k1 } from '@noble/curves/secp256k1.js'; import { hexToBytes } from '@noble/hashes/utils.js'; import { logger } from '../utils/logger'; import { bytesToBase64, base64ToBytes } from '../encoding/base64'; import type { CryptoSubtle } from '../crypto'; // Configure crypto for Node.js and test environments declare global { interface Window { crypto: CryptoSubtle; } interface Global { crypto: CryptoSubtle; } } const getCrypto = async (): Promise => { if (typeof window !== 'undefined' && window.crypto) { return window.crypto; } if (typeof global !== 'undefined' && (global as Global).crypto) { return (global as Global).crypto; } try { const cryptoModule = await import('crypto'); if (cryptoModule.webcrypto) { return cryptoModule.webcrypto as CryptoSubtle; } } catch { logger.debug('Node crypto not available'); } throw new Error('No WebCrypto implementation available'); }; class CryptoImplementation { private cryptoInstance: CryptoSubtle | null = null; private initPromise: Promise; constructor() { this.initPromise = this.initialize(); } private async initialize(): Promise { this.cryptoInstance = await getCrypto(); } private async ensureInitialized(): Promise { await this.initPromise; if (!this.cryptoInstance) { throw new Error('Crypto implementation not initialized'); } return this.cryptoInstance; } async getSubtle(): Promise { const crypto = await this.ensureInitialized(); return crypto.subtle; } async getRandomValues(array: T): Promise { const crypto = await this.ensureInitialized(); return crypto.getRandomValues(array); } } const cryptoImpl = new CryptoImplementation(); interface SharedSecret { sharedSecret: Uint8Array; } /** * Encrypts a message using NIP-04 encryption * @param message - Message to encrypt * @param senderPrivKey - Sender's private key * @param recipientPubKey - Recipient's public key * @returns Encrypted message string */ export async function encryptMessage( message: string, senderPrivKey: string, recipientPubKey: string ): Promise { try { if (!message || !senderPrivKey || !recipientPubKey) { throw new Error('Invalid input parameters'); } // Validate keys if (!/^[0-9a-f]{64}$/i.test(senderPrivKey)) { throw new Error('Invalid private key format'); } // Ensure public key is in correct format const pubKeyHex = recipientPubKey.startsWith('02') || recipientPubKey.startsWith('03') ? recipientPubKey : '02' + recipientPubKey; // Generate shared secret const sharedPoint = secp256k1.getSharedSecret(hexToBytes(senderPrivKey), hexToBytes(pubKeyHex)); const sharedX = sharedPoint.slice(1, 33); // Use only x-coordinate // Import key for AES const sharedKey = await (await cryptoImpl.getSubtle()).importKey( 'raw', sharedX.buffer, { name: 'AES-CBC', length: 256 }, false, ['encrypt'] ); // Zero shared secret material now that AES key is imported sharedX.fill(0); sharedPoint.fill(0); // Generate IV and encrypt const iv = new Uint8Array(16); await cryptoImpl.getRandomValues(iv); const encoded = new TextEncoder().encode(message); const encrypted = await (await cryptoImpl.getSubtle()).encrypt( { name: 'AES-CBC', iv }, sharedKey, encoded.buffer ); // NIP-04 standard format: base64(ciphertext) + "?iv=" + base64(iv) const ciphertextBase64 = bytesToBase64(new Uint8Array(encrypted)); const ivBase64 = bytesToBase64(iv); return ciphertextBase64 + '?iv=' + ivBase64; } catch (error) { logger.error({ error }, 'Failed to encrypt message'); throw error; } } /** * Decrypts a message using NIP-04 decryption * @param encryptedMessage - Encrypted message string * @param recipientPrivKey - Recipient's private key * @param senderPubKey - Sender's public key * @returns Decrypted message string */ export async function decryptMessage( encryptedMessage: string, recipientPrivKey: string, senderPubKey: string ): Promise { try { if (!encryptedMessage || !recipientPrivKey || !senderPubKey) { throw new Error('Invalid input parameters'); } // Validate keys if (!/^[0-9a-f]{64}$/i.test(recipientPrivKey)) { throw new Error('Invalid private key format'); } // Ensure public key is in correct format const pubKeyHex = senderPubKey.startsWith('02') || senderPubKey.startsWith('03') ? senderPubKey : '02' + senderPubKey; // Generate shared secret const sharedPoint = secp256k1.getSharedSecret(hexToBytes(recipientPrivKey), hexToBytes(pubKeyHex)); const sharedX = sharedPoint.slice(1, 33); // Use only x-coordinate // Import key for AES const sharedKey = await (await cryptoImpl.getSubtle()).importKey( 'raw', sharedX.buffer, { name: 'AES-CBC', length: 256 }, false, ['decrypt'] ); // Zero shared secret material now that AES key is imported sharedX.fill(0); sharedPoint.fill(0); // Parse NIP-04 standard format: base64(ciphertext) + "?iv=" + base64(iv) // Also support legacy hex format (iv + ciphertext concatenated) as fallback let iv: Uint8Array; let ciphertext: Uint8Array; if (encryptedMessage.includes('?iv=')) { // NIP-04 standard format const [ciphertextBase64, ivBase64] = encryptedMessage.split('?iv='); ciphertext = base64ToBytes(ciphertextBase64); iv = base64ToBytes(ivBase64); } else { // Legacy hex format fallback: first 16 bytes are IV, rest is ciphertext const encrypted = hexToBytes(encryptedMessage); iv = encrypted.slice(0, 16); ciphertext = encrypted.slice(16); } // Decrypt const decrypted = await (await cryptoImpl.getSubtle()).decrypt( { name: 'AES-CBC', iv }, sharedKey, ciphertext.buffer as ArrayBuffer ); return new TextDecoder().decode(decrypted); } catch (error) { logger.error({ error }, 'Failed to decrypt message'); throw error; } } /** * Generates a shared secret for NIP-04 encryption * @param privateKey - Private key * @param publicKey - Public key * @returns Shared secret */ export function generateSharedSecret( privateKey: string, publicKey: string ): SharedSecret { try { if (!privateKey || !publicKey) { throw new Error('Invalid input parameters'); } // Validate keys if (!/^[0-9a-f]{64}$/i.test(privateKey)) { throw new Error('Invalid private key format'); } // Ensure public key is in correct format const pubKeyHex = publicKey.startsWith('02') || publicKey.startsWith('03') ? publicKey : '02' + publicKey; // Generate shared secret const sharedPoint = secp256k1.getSharedSecret(hexToBytes(privateKey), hexToBytes(pubKeyHex)); return { sharedSecret: sharedPoint.slice(1, 33) }; // Return only x-coordinate } catch (error) { logger.error({ error }, 'Failed to generate shared secret'); throw error; } } export { generateSharedSecret as computeSharedSecret };