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) <noreply@anthropic.com>
This commit is contained in:
parent
d6b22e1156
commit
787de5bff1
2 changed files with 17 additions and 285 deletions
|
|
@ -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<typeof data>(recipient, sender.publicKey, encrypted)
|
||||
|
||||
expect(decrypted).toEqual(data)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<T = unknown>(
|
||||
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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue