crypto.js raw

   1  // crypto.js — signing + NIP-04 + NIP-44 encryption
   2  // all noble-* imports centralized here
   3  
   4  import { schnorr, secp256k1 } from 'https://esm.sh/@noble/curves@1.8.2/secp256k1'
   5  import { sha256 } from 'https://esm.sh/@noble/hashes@1.7.2/sha256'
   6  import { extract as hkdfExtract, expand as hkdfExpand } from 'https://esm.sh/@noble/hashes@1.7.2/hkdf'
   7  import { hmac } from 'https://esm.sh/@noble/hashes@1.7.2/hmac'
   8  import { bytesToHex, hexToBytes, concatBytes, utf8ToBytes } from 'https://esm.sh/@noble/hashes@1.7.2/utils'
   9  import { chacha20 } from 'https://esm.sh/@noble/ciphers@1.2.1/chacha'
  10  
  11  // ─── base64 helpers (no spread overflow) ────────────────────────────
  12  
  13  export function toBase64(bytes) {
  14    let binary = ''
  15    for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i])
  16    return btoa(binary)
  17  }
  18  
  19  export function fromBase64(b64) {
  20    return Uint8Array.from(atob(b64), c => c.charCodeAt(0))
  21  }
  22  
  23  // ─── re-exports for other modules ───────────────────────────────────
  24  
  25  export { schnorr, secp256k1, sha256, bytesToHex, hexToBytes, concatBytes }
  26  
  27  // ─── signing ────────────────────────────────────────────────────────
  28  
  29  export function signEvent(event, secretKey) {
  30    const serialized = JSON.stringify([
  31      0, event.pubkey, event.created_at, event.kind, event.tags, event.content
  32    ])
  33    const hash = sha256(new TextEncoder().encode(serialized))
  34    const id = bytesToHex(hash)
  35    const sig = bytesToHex(schnorr.sign(hash, secretKey))
  36    return { ...event, id, sig }
  37  }
  38  
  39  export function signEventWithKey(event, privkey) {
  40    return signEvent(event, privkey)
  41  }
  42  
  43  export function randomizeTimestamp(baseTime) {
  44    return baseTime - Math.floor(Math.random() * 2 * 24 * 60 * 60)
  45  }
  46  
  47  // ─── NIP-04 (AES-256-CBC via Web Crypto + secp256k1 ECDH) ──────────
  48  
  49  function nip04SharedKey(privkeyHex, pubkeyHex) {
  50    const shared = secp256k1.getSharedSecret(privkeyHex, '02' + pubkeyHex)
  51    return shared.slice(1, 33)
  52  }
  53  
  54  export async function nip04Encrypt(privkeyHex, pubkeyHex, plaintext) {
  55    const sharedKey = nip04SharedKey(privkeyHex, pubkeyHex)
  56    const iv = crypto.getRandomValues(new Uint8Array(16))
  57    const key = await crypto.subtle.importKey('raw', sharedKey, { name: 'AES-CBC' }, false, ['encrypt'])
  58    const enc = await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, key, new TextEncoder().encode(plaintext))
  59    return toBase64(new Uint8Array(enc)) + '?iv=' + toBase64(iv)
  60  }
  61  
  62  export async function nip04Decrypt(privkeyHex, pubkeyHex, ciphertext) {
  63    const [encB64, ivB64] = ciphertext.split('?iv=')
  64    const enc = fromBase64(encB64)
  65    const iv = fromBase64(ivB64)
  66    const sharedKey = nip04SharedKey(privkeyHex, pubkeyHex)
  67    const key = await crypto.subtle.importKey('raw', sharedKey, { name: 'AES-CBC' }, false, ['decrypt'])
  68    const dec = await crypto.subtle.decrypt({ name: 'AES-CBC', iv }, key, enc)
  69    return new TextDecoder().decode(dec)
  70  }
  71  
  72  // ─── NIP-44 v2 (ChaCha20 + HMAC-SHA256 + HKDF) ────────────────────
  73  
  74  const NIP44_SALT = utf8ToBytes('nip44-v2')
  75  
  76  export function nip44ConversationKey(privkeyHex, pubkeyHex) {
  77    const shared = secp256k1.getSharedSecret(privkeyHex, '02' + pubkeyHex)
  78    const sharedX = shared.slice(1, 33)
  79    return hkdfExtract(sha256, sharedX, NIP44_SALT)
  80  }
  81  
  82  function nip44MessageKeys(conversationKey, nonce) {
  83    const keys = hkdfExpand(sha256, conversationKey, nonce, 76)
  84    return {
  85      chachaKey: keys.slice(0, 32),
  86      chaChaNonce: keys.slice(32, 44),
  87      hmacKey: keys.slice(44, 76),
  88    }
  89  }
  90  
  91  function nip44CalcPadding(len) {
  92    if (len <= 32) return 32
  93    const nextPow = 1 << (Math.floor(Math.log2(len - 1)) + 1)
  94    const chunk = nextPow <= 256 ? 32 : nextPow / 8
  95    return chunk * (Math.floor((len - 1) / chunk) + 1)
  96  }
  97  
  98  function nip44Pad(plaintext) {
  99    const unpadded = utf8ToBytes(plaintext)
 100    const len = unpadded.length
 101    if (len < 1 || len > 65535) throw new Error('invalid plaintext length')
 102    const paddedLen = nip44CalcPadding(len)
 103    const out = new Uint8Array(2 + paddedLen)
 104    out[0] = (len >> 8) & 0xff
 105    out[1] = len & 0xff
 106    out.set(unpadded, 2)
 107    return out
 108  }
 109  
 110  function nip44Unpad(padded) {
 111    const len = (padded[0] << 8) | padded[1]
 112    if (len < 1 || len > padded.length - 2) throw new Error('invalid padding')
 113    const plainBytes = padded.slice(2, 2 + len)
 114    for (let i = 2 + len; i < padded.length; i++) {
 115      if (padded[i] !== 0) throw new Error('invalid padding: non-zero')
 116    }
 117    return new TextDecoder().decode(plainBytes)
 118  }
 119  
 120  export function nip44Encrypt(plaintext, conversationKey) {
 121    const nonce = crypto.getRandomValues(new Uint8Array(32))
 122    const { chachaKey, chaChaNonce, hmacKey } = nip44MessageKeys(conversationKey, nonce)
 123    const padded = nip44Pad(plaintext)
 124    const ciphertext = chacha20(chachaKey, chaChaNonce, padded)
 125    const mac = hmac(sha256, hmacKey, concatBytes(nonce, ciphertext))
 126    const payload = concatBytes(new Uint8Array([2]), nonce, ciphertext, mac)
 127    return toBase64(payload)
 128  }
 129  
 130  export function nip44Decrypt(b64payload, conversationKey) {
 131    const raw = fromBase64(b64payload)
 132    if (raw.length < 99) throw new Error('payload too short')
 133    const version = raw[0]
 134    if (version !== 2) throw new Error('unsupported nip44 version: ' + version)
 135    const nonce = raw.slice(1, 33)
 136    const ciphertext = raw.slice(33, raw.length - 32)
 137    const mac = raw.slice(raw.length - 32)
 138    const { chachaKey, chaChaNonce, hmacKey } = nip44MessageKeys(conversationKey, nonce)
 139    const expectedMac = hmac(sha256, hmacKey, concatBytes(nonce, ciphertext))
 140    let macOk = true
 141    for (let i = 0; i < 32; i++) { if (mac[i] !== expectedMac[i]) macOk = false }
 142    if (!macOk) throw new Error('nip44: invalid MAC')
 143    const padded = chacha20(chachaKey, chaChaNonce, ciphertext)
 144    return nip44Unpad(padded)
 145  }
 146  
 147  // ─── gift-wrap ──────────────────────────────────────────────────────
 148  
 149  export async function giftWrap(seal, recipientPubkey, baseTime) {
 150    const ephemeralPriv = crypto.getRandomValues(new Uint8Array(32))
 151    const ephemeralPrivHex = bytesToHex(ephemeralPriv)
 152    const ephemeralPub = bytesToHex(schnorr.getPublicKey(ephemeralPriv))
 153  
 154    const ck = nip44ConversationKey(ephemeralPrivHex, recipientPubkey)
 155    const wrapContent = nip44Encrypt(JSON.stringify(seal), ck)
 156  
 157    const wrap = {
 158      kind: 1059,
 159      content: wrapContent,
 160      tags: [['p', recipientPubkey]],
 161      created_at: randomizeTimestamp(baseTime),
 162      pubkey: ephemeralPub,
 163    }
 164    return signEventWithKey(wrap, ephemeralPriv)
 165  }
 166