1 package main
2
3 import (
4 "smesh.lol/web/common/helpers"
5 "smesh.lol/web/common/jsbridge/ext"
6 "smesh.lol/web/common/jsbridge/schnorr"
7 "smesh.lol/web/common/jsbridge/subtle"
8 )
9
10 // HD keychain state (populated when an HD vault is unlocked).
11 var (
12 hdMnemonic string // decrypted mnemonic phrase
13 hdNextAccount int // next account index to derive
14 )
15
16 const hardenedBit = 0x80000000
17
18 // --- BIP39 mnemonic generation ---
19
20 // generateMnemonic creates a 12-word BIP39 mnemonic from 128 bits of entropy.
21 func generateMnemonic() string {
22 entropy := []byte{:16}
23 subtle.RandomBytes(entropy)
24 return entropyToMnemonic(entropy)
25 }
26
27 // entropyToMnemonic converts 16 bytes of entropy to a 12-word mnemonic.
28 func entropyToMnemonic(entropy []byte) string {
29 // SHA-256 checksum: first 4 bits (128/32 = 4 bits).
30 hash := schnorr.SHA256Sum(entropy)
31
32 // 17 bytes: 16 entropy + 1 checksum (only top 4 bits used).
33 all := []byte{:17}
34 copy(all, entropy)
35 all[16] = hash[0]
36
37 // Extract 12 groups of 11 bits from the 132-bit big-endian stream.
38 var words string
39 for i := 0; i < 12; i++ {
40 bitPos := i * 11
41 byteIdx := bitPos / 8
42 bitOff := uint(bitPos % 8)
43
44 // Pull up to 3 bytes to cover the 11-bit window.
45 var val uint32
46 val = uint32(all[byteIdx]) << 16
47 if byteIdx+1 < 17 {
48 val |= uint32(all[byteIdx+1]) << 8
49 }
50 if byteIdx+2 < 17 {
51 val |= uint32(all[byteIdx+2])
52 }
53
54 // Shift left by bitOff, then take top 11 bits of the 24-bit window.
55 idx := (val << bitOff >> 13) & 0x7FF
56
57 if i > 0 {
58 words += " "
59 }
60 words += bip39Words[idx]
61 }
62 return words
63 }
64
65 // validateMnemonic checks if a mnemonic is valid BIP39 (12 or 24 words).
66 func validateMnemonic(mnemonic string) bool {
67 words := splitWords(mnemonic)
68 if len(words) != 12 && len(words) != 24 {
69 return false
70 }
71
72 // Convert words to 11-bit indices.
73 var indices []int
74 for _, w := range words {
75 idx := wordIndex(w)
76 if idx < 0 {
77 return false
78 }
79 indices = append(indices, idx)
80 }
81
82 // Reconstruct entropy + checksum from the bit stream.
83 totalBits := len(words) * 11
84 checksumBits := totalBits / 33 // 4 for 12 words, 8 for 24
85 entropyBits := totalBits - checksumBits
86 entropyBytes := entropyBits / 8
87
88 entropy := []byte{:entropyBytes}
89 csFromMnemonic := 0
90 pos := 0
91 for _, idx := range indices {
92 for b := 10; b >= 0; b-- {
93 bit := (idx >> uint(b)) & 1
94 if pos < entropyBits {
95 entropy[pos/8] |= byte(bit) << uint(7-(pos%8))
96 } else {
97 csFromMnemonic = (csFromMnemonic << 1) | bit
98 }
99 pos++
100 }
101 }
102
103 // Verify checksum against SHA-256 of entropy.
104 hash := schnorr.SHA256Sum(entropy)
105 csFromHash := int(hash[0]) >> uint(8-checksumBits)
106
107 return csFromMnemonic == csFromHash
108 }
109
110 // wordIndex returns the BIP39 word list index, or -1 if not found.
111 func wordIndex(word string) int {
112 for i := 0; i < 2048; i++ {
113 if bip39Words[i] == word {
114 return i
115 }
116 }
117 return -1
118 }
119
120 // splitWords splits a space-separated string into words.
121 func splitWords(s string) []string {
122 var words []string
123 start := -1
124 for i := 0; i < len(s); i++ {
125 if s[i] == ' ' || s[i] == '\t' || s[i] == '\n' {
126 if start >= 0 {
127 words = append(words, s[start:i])
128 start = -1
129 }
130 } else if start < 0 {
131 start = i
132 }
133 }
134 if start >= 0 {
135 words = append(words, s[start:])
136 }
137 return words
138 }
139
140 // --- BIP39 seed derivation ---
141
142 // mnemonicToSeed converts a mnemonic to a 64-byte seed via PBKDF2-HMAC-SHA512.
143 func mnemonicToSeed(mnemonic, passphrase string, fn func([]byte)) {
144 salt := []byte("mnemonic" + passphrase)
145 subtle.PBKDF2SHA512(mnemonic, salt, 2048, 64, fn)
146 }
147
148 // --- BIP32 HD key derivation ---
149
150 // bip32MasterKey derives the master key and chain code from a seed.
151 // HMAC-SHA512 with key="Bitcoin seed".
152 func bip32MasterKey(seed []byte, fn func(key, chain []byte)) {
153 subtle.HMACSHA512([]byte("Bitcoin seed"), seed, func(result []byte) {
154 if len(result) != 64 {
155 fn(nil, nil)
156 return
157 }
158 key := []byte{:32}
159 chain := []byte{:32}
160 copy(key, result[:32])
161 copy(chain, result[32:])
162 fn(key, chain)
163 })
164 }
165
166 // bip32DeriveChild derives a child key at the given index.
167 // For hardened derivation, index must include hardenedBit.
168 func bip32DeriveChild(key, chain []byte, index uint32, fn func(childKey, childChain []byte)) {
169 var data []byte
170 if index >= hardenedBit {
171 // Hardened: 0x00 || key || ser32(index)
172 data = []byte{:37}
173 data[0] = 0x00
174 copy(data[1:33], key)
175 } else {
176 // Normal: compressedPubkey(key) || ser32(index)
177 compressed, ok := schnorr.CompressedPubKey(key)
178 if !ok {
179 fn(nil, nil)
180 return
181 }
182 data = []byte{:37}
183 copy(data[:33], compressed)
184 }
185 data[33] = byte(index >> 24)
186 data[34] = byte(index >> 16)
187 data[35] = byte(index >> 8)
188 data[36] = byte(index)
189
190 subtle.HMACSHA512(chain, data, func(result []byte) {
191 if len(result) != 64 {
192 fn(nil, nil)
193 return
194 }
195 childKey, ok := schnorr.ScalarAddModN(result[:32], key)
196 if !ok {
197 fn(nil, nil)
198 return
199 }
200 childChain := []byte{:32}
201 copy(childChain, result[32:])
202 fn(childKey, childChain)
203 })
204 }
205
206 // --- NIP-06 derivation: m/44'/1237'/account'/0/0 ---
207
208 func deriveNIP06(seed []byte, account int, fn func(seckey []byte)) {
209 bip32MasterKey(seed, func(mk, mc []byte) {
210 if mk == nil {
211 fn(nil)
212 return
213 }
214 // m/44'
215 bip32DeriveChild(mk, mc, 44|hardenedBit, func(k1, c1 []byte) {
216 if k1 == nil {
217 fn(nil)
218 return
219 }
220 // m/44'/1237'
221 bip32DeriveChild(k1, c1, 1237|hardenedBit, func(k2, c2 []byte) {
222 if k2 == nil {
223 fn(nil)
224 return
225 }
226 // m/44'/1237'/account'
227 bip32DeriveChild(k2, c2, uint32(account)|hardenedBit, func(k3, c3 []byte) {
228 if k3 == nil {
229 fn(nil)
230 return
231 }
232 // m/44'/1237'/account'/0
233 bip32DeriveChild(k3, c3, 0, func(k4, c4 []byte) {
234 if k4 == nil {
235 fn(nil)
236 return
237 }
238 // m/44'/1237'/account'/0/0
239 bip32DeriveChild(k4, c4, 0, func(k5, _ []byte) {
240 fn(k5)
241 })
242 })
243 })
244 })
245 })
246 })
247 }
248
249 // --- HD vault operations ---
250
251 // hdCreateVault generates a mnemonic, creates the vault, and derives the first identity.
252 func hdCreateVault(password, name string, done func(mnemonic string)) {
253 mnemonic := generateMnemonic()
254 hdRestoreVault(password, mnemonic, name, func(ok bool) {
255 if ok {
256 done(mnemonic)
257 } else {
258 done("")
259 }
260 })
261 }
262
263 // hdRestoreVault creates a vault from an existing mnemonic and derives the first identity.
264 func hdRestoreVault(password, mnemonic, name string, done func(bool)) {
265 if !validateMnemonic(mnemonic) {
266 done(false)
267 return
268 }
269
270 createVault(password, func(ok bool) {
271 if !ok {
272 done(false)
273 return
274 }
275 hdMnemonic = mnemonic
276 hdNextAccount = 0
277 hdDeriveNext(name, func(pubkey string) {
278 if pubkey == "" {
279 done(false)
280 return
281 }
282 saveHDVault(func() {
283 done(true)
284 })
285 })
286 })
287 }
288
289 // hdDeriveNext derives the next identity from the HD mnemonic.
290 func hdDeriveNext(name string, done func(pubkey string)) {
291 if hdMnemonic == "" {
292 done("")
293 return
294 }
295
296 account := hdNextAccount
297 mnemonicToSeed(hdMnemonic, "", func(seed []byte) {
298 if len(seed) == 0 {
299 done("")
300 return
301 }
302 deriveNIP06(seed, account, func(sk []byte) {
303 if sk == nil {
304 done("")
305 return
306 }
307 pk, ok := schnorr.PubKeyFromSecKey(sk)
308 if !ok {
309 done("")
310 return
311 }
312 pkHex := helpers.HexEncode(pk)
313 skHex := helpers.HexEncode(sk)
314
315 // Check for duplicate.
316 for _, id := range identities {
317 if id.Pubkey == pkHex {
318 done("")
319 return
320 }
321 }
322
323 identities = append(identities, identity{Pubkey: pkHex, Seckey: skHex, Name: name})
324 if activeIdx < 0 {
325 activeIdx = 0
326 }
327 hdNextAccount = account + 1
328 saveHDVault(func() {
329 done(pkHex)
330 })
331 })
332 })
333 }
334
335 // --- HD vault persistence ---
336
337 // saveHDVault saves the vault with the HD mnemonic field.
338 func saveHDVault(done func()) {
339 if !vaultOpen {
340 if done != nil {
341 done()
342 }
343 return
344 }
345
346 if hdMnemonic == "" {
347 // Non-HD vault, use normal save.
348 saveVault(done)
349 return
350 }
351
352 // Encrypt mnemonic, then build vault JSON with HD fields.
353 encryptField(hdMnemonic, vaultKey, vaultIV, func(encMnemonic string) {
354 encryptAllIdentities(vaultKey, vaultIV, func(idJSON string) {
355 s := "{\"version\":" + itoa(vaultVersion) +
356 ",\"iv\":" + helpers.JsonString(helpers.Base64Encode(vaultIV)) +
357 ",\"vaultHash\":" + helpers.JsonString(vaultHash)
358
359 if vaultVersion >= 2 && vaultSalt != nil {
360 s += ",\"salt\":" + helpers.JsonString(helpers.Base64Encode(vaultSalt))
361 }
362
363 s += ",\"mnemonic\":" + helpers.JsonString(encMnemonic)
364 s += ",\"hdNextAccount\":" + itoa(hdNextAccount)
365 s += ",\"identities\":" + idJSON
366 s += ",\"permissions\":[]"
367 s += ",\"relays\":[]"
368 s += ",\"selectedIdentityId\":null"
369 s += "}"
370
371 vaultRawCache = s
372 ext.StorageSet(vaultStorageKey, s)
373 if done != nil {
374 done()
375 }
376 })
377 })
378 }
379
380 // loadHDFields decrypts the HD-specific fields after vault unlock.
381 // Called from finishUnlock when mnemonic field is present.
382 func loadHDFields(data string, key, iv []byte, done func()) {
383 encMnemonic := helpers.JsonGetString(data, "mnemonic")
384 if encMnemonic == "" {
385 hdMnemonic = ""
386 hdNextAccount = 0
387 done()
388 return
389 }
390 decryptField(encMnemonic, key, iv, func(m string) {
391 hdMnemonic = m
392 // Parse hdNextAccount.
393 naStr := helpers.JsonGetValue(data, "hdNextAccount")
394 hdNextAccount = parseSimpleInt(naStr)
395 done()
396 })
397 }
398
399 // parseSimpleInt parses a non-negative integer from a string.
400 func parseSimpleInt(s string) int {
401 n := 0
402 for i := 0; i < len(s); i++ {
403 c := s[i]
404 if c >= '0' && c <= '9' {
405 n = n*10 + int(c-'0')
406 } else {
407 break
408 }
409 }
410 return n
411 }
412
413 // probeHDAccount derives the key at a given account index without adding to vault.
414 // Returns the hex pubkey, or empty string on failure.
415 func probeHDAccount(account int, fn func(string)) {
416 if hdMnemonic == "" {
417 fn("")
418 return
419 }
420 mnemonicToSeed(hdMnemonic, "", func(seed []byte) {
421 if len(seed) == 0 {
422 fn("")
423 return
424 }
425 deriveNIP06(seed, account, func(sk []byte) {
426 if sk == nil {
427 fn("")
428 return
429 }
430 pk, ok := schnorr.PubKeyFromSecKey(sk)
431 if !ok {
432 fn("")
433 return
434 }
435 fn(helpers.HexEncode(pk))
436 })
437 })
438 }
439
440 // lockHD clears HD state.
441 func lockHD() {
442 hdMnemonic = ""
443 hdNextAccount = 0
444 }
445
446 // exportHDFields encrypts mnemonic with the given key/iv for vault export.
447 // Returns extra JSON fields (with leading comma) or empty string if not HD.
448 func exportHDFields(key, iv []byte, fn func(string)) {
449 if hdMnemonic == "" {
450 fn("")
451 return
452 }
453 encryptField(hdMnemonic, key, iv, func(encMnemonic string) {
454 fn(",\"mnemonic\":" + helpers.JsonString(encMnemonic) +
455 ",\"hdNextAccount\":" + itoa(hdNextAccount))
456 })
457 }
458