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