Move 9 development/testing .mjs scripts out of the package root into dev/ to keep the published package clean. Update relative imports and dev.sh reference. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
111 lines
3.6 KiB
JavaScript
111 lines
3.6 KiB
JavaScript
/**
|
|
* NIP-44 v1 Implementation
|
|
*
|
|
* This is the XChaCha20-based encryption used by Lightning.Pub for Kind 21000 RPC events.
|
|
* It differs from standard NIP-44 v2 (used in nostr-tools) which uses ChaCha20-Poly1305.
|
|
*/
|
|
|
|
import { base64 } from '@scure/base'
|
|
import { randomBytes } from '@noble/hashes/utils.js'
|
|
import { streamXOR as xchacha20 } from '@stablelib/xchacha20'
|
|
import { secp256k1 } from '@noble/curves/secp256k1.js'
|
|
import { sha256 } from '@noble/hashes/sha2.js'
|
|
|
|
const XCHACHA20_VERSION = 1
|
|
|
|
/**
|
|
* Convert hex string to Uint8Array
|
|
* @param {string} hex - Hex string
|
|
* @returns {Uint8Array}
|
|
*/
|
|
function hexToBytes(hex) {
|
|
const bytes = new Uint8Array(hex.length / 2)
|
|
for (let i = 0; i < hex.length; i += 2) {
|
|
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16)
|
|
}
|
|
return bytes
|
|
}
|
|
|
|
/**
|
|
* Get shared secret for NIP-44 v1 encryption
|
|
* @param {Uint8Array|string} privateKey - Private key (32 bytes or hex string)
|
|
* @param {string} publicKey - Public key (32 bytes hex string, no prefix)
|
|
* @returns {Uint8Array} - 32-byte shared secret
|
|
*/
|
|
export function getConversationKey(privateKey, publicKey) {
|
|
// Convert private key to Uint8Array if it's a hex string
|
|
const privKeyBytes = typeof privateKey === 'string' ? hexToBytes(privateKey) : privateKey
|
|
|
|
// Convert public key (with 02 prefix) to Uint8Array
|
|
const pubKeyBytes = hexToBytes('02' + publicKey)
|
|
|
|
// Get ECDH shared point
|
|
const sharedPoint = secp256k1.getSharedSecret(privKeyBytes, pubKeyBytes)
|
|
|
|
// Hash the x-coordinate of the shared point
|
|
return sha256(sharedPoint.slice(1, 33))
|
|
}
|
|
|
|
/**
|
|
* Encrypt content using NIP-44 v1 (XChaCha20)
|
|
* @param {string} content - Plaintext content to encrypt
|
|
* @param {Uint8Array} conversationKey - 32-byte conversation key from getConversationKey
|
|
* @returns {string} - Base64-encoded encrypted payload
|
|
*/
|
|
export function encrypt(content, conversationKey) {
|
|
const nonce = randomBytes(24)
|
|
const plaintext = new TextEncoder().encode(content)
|
|
|
|
// XChaCha20 stream cipher - encrypts in place
|
|
const ciphertext = new Uint8Array(plaintext.length)
|
|
xchacha20(conversationKey, nonce, plaintext, ciphertext)
|
|
|
|
// Encode: version byte + nonce + ciphertext
|
|
return base64.encode(new Uint8Array([XCHACHA20_VERSION, ...nonce, ...ciphertext]))
|
|
}
|
|
|
|
/**
|
|
* Decrypt content using NIP-44 v1 (XChaCha20)
|
|
* @param {string} content - Base64-encoded encrypted payload
|
|
* @param {Uint8Array} conversationKey - 32-byte conversation key from getConversationKey
|
|
* @returns {string} - Decrypted plaintext
|
|
*/
|
|
export function decrypt(content, conversationKey) {
|
|
const payload = decodePayload(content)
|
|
|
|
// XChaCha20 stream cipher - decrypts in place
|
|
const plaintext = new Uint8Array(payload.ciphertext.length)
|
|
xchacha20(conversationKey, payload.nonce, payload.ciphertext, plaintext)
|
|
|
|
return new TextDecoder().decode(plaintext)
|
|
}
|
|
|
|
/**
|
|
* Decode encrypted payload (supports both formats)
|
|
* @param {string} content - Base64-encoded or JSON-encoded payload
|
|
* @returns {{nonce: Uint8Array, ciphertext: Uint8Array}}
|
|
*/
|
|
function decodePayload(content) {
|
|
// Check for JSON format
|
|
if (content.startsWith('{') && content.endsWith('}')) {
|
|
const parsed = JSON.parse(content)
|
|
if (parsed.v !== XCHACHA20_VERSION) {
|
|
throw new Error(`Unsupported encryption version: ${parsed.v}`)
|
|
}
|
|
return {
|
|
nonce: base64.decode(parsed.nonce),
|
|
ciphertext: base64.decode(parsed.ciphertext),
|
|
}
|
|
}
|
|
|
|
// Binary format: version byte + nonce (24 bytes) + ciphertext
|
|
const buf = base64.decode(content)
|
|
if (buf[0] !== XCHACHA20_VERSION) {
|
|
throw new Error(`Unsupported encryption version: ${buf[0]}`)
|
|
}
|
|
|
|
return {
|
|
nonce: buf.subarray(1, 25),
|
|
ciphertext: buf.subarray(25),
|
|
}
|
|
}
|