hd.mx raw

   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