From 787de5bff1f1c7a41d714098f7fe57ac8c5ba596 Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 18 Jun 2026 19:57:02 +0200 Subject: [PATCH] refactor(nostr-client): retire dead NIP-44 v1 / Lightning.Pub path Drop encryptContent / decryptContent / decryptJSON and the hand-rolled XChaCha20 + v1 conversation-key machinery they depended on (~230 lines). The only callers were createMachineStatusEvent / createTransactionEvent, which had no callers in apps/ and were removed in the Signer migration. This closes the open question carried in aiolabs/bitspire#52: every live encryption path is NIP-44 v2, and the nsecbunkerd signer is v2-only, so there is nothing to keep v1 for. encryptContentV2 / decryptContentV2 stay as the v2 helpers used by the dormant CLINK client + tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/__tests__/encryption.test.ts | 31 +- packages/nostr-client/src/encryption.ts | 271 +----------------- 2 files changed, 17 insertions(+), 285 deletions(-) diff --git a/packages/nostr-client/src/__tests__/encryption.test.ts b/packages/nostr-client/src/__tests__/encryption.test.ts index 7595332..d684841 100644 --- a/packages/nostr-client/src/__tests__/encryption.test.ts +++ b/packages/nostr-client/src/__tests__/encryption.test.ts @@ -1,46 +1,33 @@ import { describe, it, expect } from 'vitest' import { generateIdentity } from '../identity.js' -import { encryptContent, decryptContent, decryptJSON } from '../encryption.js' +import { encryptContentV2, decryptContentV2 } from '../encryption.js' -describe('encryption', () => { - describe('encryptContent / decryptContent', () => { +describe('encryption (NIP-44 v2)', () => { + describe('encryptContentV2 / decryptContentV2', () => { it('should encrypt and decrypt string content', () => { const sender = generateIdentity() const recipient = generateIdentity() const message = 'Hello, Nostr!' - const encrypted = encryptContent(sender, recipient.publicKey, message) + const encrypted = encryptContentV2(sender, recipient.publicKey, message) expect(encrypted).not.toBe(message) expect(typeof encrypted).toBe('string') - const decrypted = decryptContent(recipient, sender.publicKey, encrypted) + const decrypted = decryptContentV2(recipient, sender.publicKey, encrypted) expect(decrypted).toBe(message) }) - it('should encrypt and decrypt object content', () => { + it('should encrypt and decrypt object content (serialized to JSON)', () => { const sender = generateIdentity() const recipient = generateIdentity() - const data = { amount: 1000, currency: 'USD', timestamp: Date.now() } + const data = { amount: 1000, currency: 'USD', timestamp: 1_700_000_000 } - const encrypted = encryptContent(sender, recipient.publicKey, data) - const decrypted = decryptContent(recipient, sender.publicKey, encrypted) + const encrypted = encryptContentV2(sender, recipient.publicKey, data) + const decrypted = decryptContentV2(recipient, sender.publicKey, encrypted) expect(JSON.parse(decrypted)).toEqual(data) }) }) - - describe('decryptJSON', () => { - it('should decrypt and parse JSON directly', () => { - const sender = generateIdentity() - const recipient = generateIdentity() - const data = { test: true, nested: { value: 42 } } - - const encrypted = encryptContent(sender, recipient.publicKey, data) - const decrypted = decryptJSON(recipient, sender.publicKey, encrypted) - - expect(decrypted).toEqual(data) - }) - }) }) diff --git a/packages/nostr-client/src/encryption.ts b/packages/nostr-client/src/encryption.ts index 8d6a1d3..7b0980f 100644 --- a/packages/nostr-client/src/encryption.ts +++ b/packages/nostr-client/src/encryption.ts @@ -1,274 +1,19 @@ /** - * NIP-44 Encryption utilities + * NIP-44 v2 encryption helpers. * - * Supports both: - * - v1: Lightning.Pub's custom format (xchacha20, used for kind 21000) - * - v2: Standard NIP-44 v2 (used for other kinds) + * Thin wrappers over nostr-tools `nip44.v2`, used for operator-directed + * kind-30078 content and by the dormant CLINK client. Kind-21000 RPC and + * the availability/cassette paths route through the `Signer` abstraction + * (`signer.ts`) instead. * - * NOTE: Lightning.Pub currently only supports NIP-44 v1 for kind 21000 RPC. - * A contribution to support v2 would be welcome: - * https://github.com/shocknet/Lightning.Pub + * The legacy NIP-44 v1 / Lightning.Pub XChaCha20 format was retired with + * the LNbits migration (aiolabs/bitspire#52): the nsecbunkerd signer is + * NIP-44 v2 only and nothing live used v1. */ import { nip44 } from 'nostr-tools' -import { bytesToHex, hexToBytes } from 'nostr-tools/utils' -import { secp256k1 } from '@noble/curves/secp256k1.js' -import { sha256 } from '@noble/hashes/sha2.js' import type { MachineIdentity } from './types.js' -const V1_ENCRYPTION_VERSION = 1 - -// Base64 utilities that work in both browser and Node -function base64Encode(bytes: Uint8Array): string { - if (typeof btoa !== 'undefined') { - let binary = '' - for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i]!) - } - return btoa(binary) - } - return Buffer.from(bytes).toString('base64') -} - -function base64Decode(str: string): Uint8Array { - if (typeof atob !== 'undefined') { - const binary = atob(str) - const bytes = new Uint8Array(binary.length) - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i) - } - return bytes - } - return new Uint8Array(Buffer.from(str, 'base64')) -} - -// Crypto random bytes -function getRandomBytes(length: number): Uint8Array { - if (typeof crypto !== 'undefined' && crypto.getRandomValues) { - return crypto.getRandomValues(new Uint8Array(length)) - } - // Node.js fallback - const { randomBytes } = require('crypto') as typeof import('crypto') - return new Uint8Array(randomBytes(length)) -} - -// XChaCha20 implementation -function rotl(a: number, b: number): number { - return ((a << b) | (a >>> (32 - b))) >>> 0 -} - -function quarterRound(state: Uint32Array, a: number, b: number, c: number, d: number): void { - state[a] = (state[a]! + state[b]!) >>> 0 - state[d] = rotl(state[d]! ^ state[a]!, 16) - state[c] = (state[c]! + state[d]!) >>> 0 - state[b] = rotl(state[b]! ^ state[c]!, 12) - state[a] = (state[a]! + state[b]!) >>> 0 - state[d] = rotl(state[d]! ^ state[a]!, 8) - state[c] = (state[c]! + state[d]!) >>> 0 - state[b] = rotl(state[b]! ^ state[c]!, 7) -} - -function chacha20Block(key: Uint8Array, nonce: Uint8Array, counter: number): Uint8Array { - const state = new Uint32Array(16) - const keyBuf = new ArrayBuffer(32) - new Uint8Array(keyBuf).set(key) - const nonceBuf = new ArrayBuffer(12) - new Uint8Array(nonceBuf).set(nonce) - const view = new DataView(keyBuf) - const nonceView = new DataView(nonceBuf) - - // "expand 32-byte k" - state[0] = 0x61707865 - state[1] = 0x3320646e - state[2] = 0x79622d32 - state[3] = 0x6b206574 - - for (let i = 0; i < 8; i++) { - state[4 + i] = view.getUint32(i * 4, true) - } - - state[12] = counter >>> 0 - for (let i = 0; i < 3; i++) { - state[13 + i] = nonceView.getUint32(i * 4, true) - } - - const working = new Uint32Array(state) - - for (let i = 0; i < 10; i++) { - quarterRound(working, 0, 4, 8, 12) - quarterRound(working, 1, 5, 9, 13) - quarterRound(working, 2, 6, 10, 14) - quarterRound(working, 3, 7, 11, 15) - quarterRound(working, 0, 5, 10, 15) - quarterRound(working, 1, 6, 11, 12) - quarterRound(working, 2, 7, 8, 13) - quarterRound(working, 3, 4, 9, 14) - } - - const output = new Uint8Array(64) - const outView = new DataView(output.buffer) - for (let i = 0; i < 16; i++) { - outView.setUint32(i * 4, (working[i]! + state[i]!) >>> 0, true) - } - - return output -} - -function hchacha20(key: Uint8Array, nonce: Uint8Array): Uint8Array { - const state = new Uint32Array(16) - const keyBuf = new ArrayBuffer(32) - new Uint8Array(keyBuf).set(key) - const nonceBuf = new ArrayBuffer(16) - new Uint8Array(nonceBuf).set(nonce) - const keyView = new DataView(keyBuf) - const nonceView = new DataView(nonceBuf) - - state[0] = 0x61707865 - state[1] = 0x3320646e - state[2] = 0x79622d32 - state[3] = 0x6b206574 - - for (let i = 0; i < 8; i++) { - state[4 + i] = keyView.getUint32(i * 4, true) - } - - for (let i = 0; i < 4; i++) { - state[12 + i] = nonceView.getUint32(i * 4, true) - } - - for (let i = 0; i < 10; i++) { - quarterRound(state, 0, 4, 8, 12) - quarterRound(state, 1, 5, 9, 13) - quarterRound(state, 2, 6, 10, 14) - quarterRound(state, 3, 7, 11, 15) - quarterRound(state, 0, 5, 10, 15) - quarterRound(state, 1, 6, 11, 12) - quarterRound(state, 2, 7, 8, 13) - quarterRound(state, 3, 4, 9, 14) - } - - const result = new Uint8Array(32) - const resultView = new DataView(result.buffer) - resultView.setUint32(0, state[0]!, true) - resultView.setUint32(4, state[1]!, true) - resultView.setUint32(8, state[2]!, true) - resultView.setUint32(12, state[3]!, true) - resultView.setUint32(16, state[12]!, true) - resultView.setUint32(20, state[13]!, true) - resultView.setUint32(24, state[14]!, true) - resultView.setUint32(28, state[15]!, true) - - return result -} - -function xchacha20Encrypt(key: Uint8Array, nonce: Uint8Array, data: Uint8Array): Uint8Array { - const subkey = hchacha20(key, nonce.subarray(0, 16)) - const chacha20Nonce = new Uint8Array(12) - chacha20Nonce.set(nonce.subarray(16, 24), 4) - - const result = new Uint8Array(data.length) - let counter = 0 - - for (let offset = 0; offset < data.length; offset += 64) { - const block = chacha20Block(subkey, chacha20Nonce, counter++) - const remaining = Math.min(64, data.length - offset) - for (let i = 0; i < remaining; i++) { - result[offset + i] = data[offset + i]! ^ block[i]! - } - } - - return result -} - -/** - * Get shared secret for v1 encryption (Lightning.Pub format) - * - * NIP-44 v1 key derivation: - * sha256(secp256k1.getSharedSecret(privKey, "02" + pubKey).slice(1, 33)) - * - * This differs from v2 which uses HKDF instead of plain SHA-256. - */ -function getConversationKeyV1(privateKey: Uint8Array, publicKey: string): Uint8Array { - // Compute ECDH shared point with compressed pubkey (02 prefix for even y) - const compressedPubkey = hexToBytes('02' + publicKey) - const sharedPoint = secp256k1.getSharedSecret(privateKey, compressedPubkey) - // Take x-coordinate only (skip the 0x04 prefix byte) and hash with SHA-256 - return sha256(sharedPoint.slice(1, 33)) -} - -/** - * Encrypt content using v1 format (Lightning.Pub's format for kind 21000) - */ -export function encryptV1(content: string, sharedSecret: Uint8Array): string { - const nonce = getRandomBytes(24) - const plaintext = new TextEncoder().encode(content) - const ciphertext = xchacha20Encrypt(sharedSecret, nonce, plaintext) - - const payload = new Uint8Array(1 + nonce.length + ciphertext.length) - payload[0] = V1_ENCRYPTION_VERSION - payload.set(nonce, 1) - payload.set(ciphertext, 25) - - return base64Encode(payload) -} - -/** - * Decrypt content using v1 format (Lightning.Pub's format) - */ -export function decryptV1(content: string, sharedSecret: Uint8Array): string { - const buf = base64Decode(content) - - if (buf[0] !== V1_ENCRYPTION_VERSION) { - throw new Error('Encryption version unsupported') - } - - const nonce = buf.subarray(1, 25) - const ciphertext = buf.subarray(25) - const plaintext = xchacha20Encrypt(sharedSecret, nonce, ciphertext) // XChaCha20 is symmetric - - return new TextDecoder().decode(plaintext) -} - -/** - * Encrypt content for Lightning.Pub RPC (kind 21000) - * Uses v1 format that Lightning.Pub expects - */ -export function encryptContent( - identity: MachineIdentity, - recipientPubkey: string, - content: unknown -): string { - const plaintext = typeof content === 'string' ? content : JSON.stringify(content) - const sharedSecret = getConversationKeyV1(identity.privateKey, recipientPubkey) - return encryptV1(plaintext, sharedSecret) -} - -/** - * Decrypt content from Lightning.Pub RPC (kind 21000) - * Uses v1 format - */ -export function decryptContent( - identity: MachineIdentity, - senderPubkey: string, - ciphertext: string -): string { - const sharedSecret = getConversationKeyV1(identity.privateKey, senderPubkey) - return decryptV1(ciphertext, sharedSecret) -} - -/** - * Decrypt and parse JSON content - */ -export function decryptJSON( - identity: MachineIdentity, - senderPubkey: string, - ciphertext: string -): T { - const plaintext = decryptContent(identity, senderPubkey, ciphertext) - return JSON.parse(plaintext) as T -} - -// Also export v2 functions for other use cases (non-RPC encrypted messages) export const encryptContentV2 = ( identity: MachineIdentity, recipientPubkey: string,