vault.mx raw

   1  package main
   2  
   3  import (
   4  	"git.smesh.lol/smesh/web/common/crypto/aes256gcm"
   5  	"crypto/sha256"
   6  	"git.smesh.lol/smesh/web/common/helpers"
   7  	"git.smesh.lol/smesh/web/common/jsbridge/ext"
   8  	"git.smesh.lol/smesh/web/common/jsbridge/schnorr"
   9  	"git.smesh.lol/smesh/web/common/jsbridge/subtle"
  10  )
  11  
  12  // Vault: Argon2id (v2) or PBKDF2 (v1, auto-migrated) + AES-256-GCM field
  13  // encryption. Compatible with the Plebeian/Smesh signer format.
  14  // Stored in browser.storage.local under vaultStorageKey.
  15  
  16  const (
  17  	vaultStorageKey = "smesh-vault"
  18  	pbkdf2Salt      = "3e7cdebd-3b4c-4125-a18c-05750cad8ec3"
  19  	pbkdf2Iters     = 1000
  20  	argon2T         = 3
  21  	// argon2MLegacy = original 64 MB. Existing vaults use this; new vaults use
  22  	// argon2MNew. 64 MB allocates a 8MB block that can trip TinyGo GC stack
  23  	// overflow on mobile WASM (limited linear memory, narrow GC stack).
  24  	argon2MLegacy = 65536
  25  	argon2MNew    = 4096 // 4 MB - mobile-safe, still ~4 GPU-min/guess
  26  	argon2P       = 1
  27  	argon2DKLen   = 32
  28  )
  29  
  30  type identity struct {
  31  	Pubkey string // hex
  32  	Seckey string // hex, plaintext in memory while unlocked
  33  	Name   string
  34  }
  35  
  36  var (
  37  	vaultKey     []byte
  38  	vaultSalt    []byte // 32-byte Argon2id salt (v2+ only)
  39  	vaultHash    string // hex SHA-256 of password, for fast unlock check
  40  	vaultVersion int32    // 1, 2, or 3
  41  	vaultArgon2M int32    // argon2 memory param this vault was encrypted with
  42  	vaultOpen    bool
  43  	vaultExists  bool
  44  	vaultRawCache string // cached JSON from storage, set in loadVault callback
  45  
  46  	// v2 compat: shared IV from stored vault, used only to decrypt v2 fields during migration.
  47  	v2IV []byte
  48  
  49  	identities []identity
  50  	activeIdx  int32
  51  
  52  	// lastUnlockErr is set by unlockVault on failure for diagnostic surface
  53  	// via mgmtUnlockVault. Cleared on success. Values: "no-vault", "no-hash",
  54  	// "wrong-password", "bad-iv", "kdf-failed", "decrypt-failed".
  55  	lastUnlockErr string
  56  )
  57  
  58  func loadVault() {
  59  	ext.StorageGet(vaultStorageKey, func(data string) {
  60  		vaultRawCache = data
  61  		vaultExists = data != ""
  62  		if data == "" {
  63  			ext.SignalReady()
  64  			return
  65  		}
  66  		// Use heap-built key to avoid literal address relocation bug in wasm32.
  67  		kVersion := string(append([]byte(nil), 'v', 'e', 'r', 's', 'i', 'o', 'n'))
  68  		ver := helpers.JsonGetValue(data, kVersion)
  69  		if len(ver) == 1 && ver[0] == '0' {
  70  			restorePlaintextVault(data)
  71  			ext.SignalReady()
  72  			return
  73  		}
  74  		if ext.IsInPage() {
  75  			tryRestoreSession()
  76  		}
  77  		ext.SignalReady()
  78  	})
  79  }
  80  
  81  func activeIdentity() (p *identity) {
  82  	if !vaultOpen || activeIdx < 0 || activeIdx >= len(identities) {
  83  		return nil
  84  	}
  85  	return &identities[activeIdx]
  86  }
  87  
  88  func restoreActiveIdx(pk string) {
  89  	if pk == "" {
  90  		return
  91  	}
  92  	for i, id := range identities {
  93  		if id.Pubkey == pk {
  94  			activeIdx = i
  95  			return
  96  		}
  97  	}
  98  }
  99  
 100  func createVault(password string) (ok bool) {
 101  	hash := passwordHash(password)
 102  	if hash == "" {
 103  		return false
 104  	}
 105  	salt := []byte{:32}
 106  	subtle.RandomBytes(salt)
 107  	key := subtle.Argon2idDeriveKey(password, salt, argon2T, argon2MNew, argon2P, argon2DKLen)
 108  	if len(key) == 0 {
 109  		return false
 110  	}
 111  	vaultKey = key
 112  	vaultSalt = salt
 113  	vaultHash = hash
 114  	vaultVersion = 3
 115  	vaultArgon2M = argon2MNew
 116  	vaultOpen = true
 117  	identities = nil
 118  	activeIdx = -1
 119  	vaultExists = true
 120  	saveVault()
 121  	return true
 122  }
 123  
 124  func unlockVault(password string) (ok bool) {
 125  	lastUnlockErr = ""
 126  	data := vaultRawCache
 127  	if data == "" {
 128  		lastUnlockErr = "no-vault"
 129  		return false
 130  	}
 131  	if len(data) > 0 && data[0] != '{' {
 132  		return unlockLegacy(data, password)
 133  	}
 134  	kVaultHash := string(append([]byte(nil), 'v', 'a', 'u', 'l', 't', 'H', 'a', 's', 'h'))
 135  	kIV := string(append([]byte(nil), 'i', 'v'))
 136  	kSalt := string(append([]byte(nil), 's', 'a', 'l', 't'))
 137  	kVersion := string(append([]byte(nil), 'v', 'e', 'r', 's', 'i', 'o', 'n'))
 138  	kArgon2M := string(append([]byte(nil), 'a', 'r', 'g', 'o', 'n', '2', 'M'))
 139  	storedHash := helpers.JsonGetString(data, kVaultHash)
 140  	if storedHash == "" {
 141  		lastUnlockErr = "no-hash"
 142  		return false
 143  	}
 144  	computed := passwordHash(password)
 145  	vaultHash = storedHash
 146  	if computed != storedHash {
 147  		// Include hash prefixes so the diagnostic distinguishes:
 148  		// - typo (different prefixes, expected for a real typo)
 149  		// - corrupted save (computed stable across attempts, stored differs)
 150  		// - non-deterministic hash on WASM (computed differs across attempts)
 151  		cp := computed
 152  		if len(cp) > 12 {
 153  			cp = cp[:12]
 154  		}
 155  		sp := storedHash
 156  		if len(sp) > 12 {
 157  			sp = sp[:12]
 158  		}
 159  		lastUnlockErr = "wrong-password computed=" | cp | " stored=" | sp | " pwlen=" | helpers.Itoa(int64(len(password)))
 160  		return false
 161  	}
 162  	ver := helpers.JsonGetValue(data, kVersion)
 163  	storedVersion := 2
 164  	if len(ver) == 1 && ver[0] == '3' {
 165  		storedVersion = 3
 166  	}
 167  	// v2 vaults have a shared IV; v3 has per-field IVs (no "iv" in JSON).
 168  	var legacyIV []byte
 169  	if storedVersion <= 2 {
 170  		ivB64 := helpers.JsonGetString(data, kIV)
 171  		legacyIV = helpers.Base64Decode(ivB64)
 172  		if len(legacyIV) != 12 {
 173  			lastUnlockErr = "bad-iv"
 174  			return false
 175  		}
 176  	}
 177  	// Read argon2M param. Missing field = legacy vault (m=65536).
 178  	argMRaw := helpers.JsonGetValue(data, kArgon2M)
 179  	argM := argon2MLegacy
 180  	if argMRaw != "" {
 181  		v := 0
 182  		for i := 0; i < len(argMRaw); i++ {
 183  			c := argMRaw[i]
 184  			if c >= '0' && c <= '9' {
 185  				v = v*10 + int32(c-'0')
 186  			} else {
 187  				v = 0
 188  				break
 189  			}
 190  		}
 191  		if v > 0 {
 192  			argM = v
 193  		}
 194  	}
 195  	saltB64 := helpers.JsonGetString(data, kSalt)
 196  	if saltB64 != "" {
 197  		salt := helpers.Base64Decode(saltB64)
 198  		key := subtle.Argon2idDeriveKey(password, salt, argon2T, argM, argon2P, argon2DKLen)
 199  		if len(key) == 0 {
 200  			lastUnlockErr = "kdf-failed"
 201  			return false
 202  		}
 203  		if !finishUnlock(data, key, legacyIV, salt, storedVersion) {
 204  			lastUnlockErr = "decrypt-failed"
 205  			return false
 206  		}
 207  		vaultArgon2M = argM
 208  		return true
 209  	}
 210  	key := subtle.PBKDF2DeriveKey(password, []byte(pbkdf2Salt), pbkdf2Iters)
 211  	if len(key) == 0 {
 212  		lastUnlockErr = "kdf-failed"
 213  		return false
 214  	}
 215  	if !finishUnlock(data, key, legacyIV, nil, 1) {
 216  		lastUnlockErr = "decrypt-failed"
 217  		return false
 218  	}
 219  	return true
 220  }
 221  
 222  func finishUnlock(data string, key, legacyIV, salt []byte, version int32) (ok bool) {
 223  	vaultKey = key
 224  	vaultSalt = salt
 225  	vaultVersion = version
 226  	v2IV = legacyIV
 227  	vaultOpen = true
 228  	// Restore vaultHash and vaultArgon2M from stored JSON so a subsequent
 229  	// saveVault doesn't see len(vaultHash)==0 and wipe storage with "{}".
 230  	// On a fresh page load, globals reset to zero and only data fields are
 231  	// rehydrated by callers - these two were missed before.
 232  	kVaultHashF := string(append([]byte(nil), 'v', 'a', 'u', 'l', 't', 'H', 'a', 's', 'h'))
 233  	if h := helpers.JsonGetString(data, kVaultHashF); h != "" {
 234  		vaultHash = h
 235  	}
 236  	kArgon2MF := string(append([]byte(nil), 'a', 'r', 'g', 'o', 'n', '2', 'M'))
 237  	if vaultArgon2M == 0 {
 238  		raw := helpers.JsonGetValue(data, kArgon2MF)
 239  		v := 0
 240  		for i := 0; i < len(raw); i++ {
 241  			c := raw[i]
 242  			if c < '0' || c > '9' { v = 0; break }
 243  			v = v*10 + int32(c-'0')
 244  		}
 245  		if v > 0 {
 246  			vaultArgon2M = v
 247  		} else {
 248  			vaultArgon2M = argon2MLegacy
 249  		}
 250  	}
 251  	kIdentities := string(append([]byte(nil), 'i', 'd', 'e', 'n', 't', 'i', 't', 'i', 'e', 's'))
 252  	idsRaw := helpers.JsonGetValue(data, kIdentities)
 253  	identities = nil
 254  	activeIdx = -1
 255  	if idsRaw != "" {
 256  		kPubkey := string(append([]byte(nil), 'p', 'u', 'b', 'k', 'e', 'y'))
 257  		kSeckey := string(append([]byte(nil), 's', 'e', 'c', 'k', 'e', 'y'))
 258  		kName := string(append([]byte(nil), 'n', 'a', 'm', 'e'))
 259  		i := 0
 260  		for i < len(idsRaw) && idsRaw[i] != '[' {
 261  			i++
 262  		}
 263  		i++
 264  		for i < len(idsRaw) {
 265  			for i < len(idsRaw) && idsRaw[i] != '{' && idsRaw[i] != ']' {
 266  				i++
 267  			}
 268  			if i >= len(idsRaw) || idsRaw[i] == ']' {
 269  				break
 270  			}
 271  			end := i + 1
 272  			depth := 1
 273  			for end < len(idsRaw) && depth > 0 {
 274  				if idsRaw[end] == '{' {
 275  					depth++
 276  				} else if idsRaw[end] == '}' {
 277  					depth--
 278  				} else if idsRaw[end] == '"' {
 279  					end++
 280  					for end < len(idsRaw) && idsRaw[end] != '"' {
 281  						if idsRaw[end] == '\\' {
 282  							end++
 283  						}
 284  						end++
 285  					}
 286  				}
 287  				end++
 288  			}
 289  			obj := idsRaw[i:end]
 290  			pk := helpers.JsonGetString(obj, kPubkey)
 291  			encSk := helpers.JsonGetString(obj, kSeckey)
 292  			name := helpers.JsonGetString(obj, kName)
 293  			if pk != "" {
 294  				sk := encSk
 295  				decryptOK := true
 296  				if version >= 2 && encSk != "" {
 297  					if plain, ok := decryptField(encSk); ok {
 298  						sk = plain
 299  					} else {
 300  						decryptOK = false
 301  					}
 302  				}
 303  				if !decryptOK {
 304  					// Bail. Caller surfaces this as decrypt-failed.
 305  					lockVault()
 306  					return false
 307  				}
 308  				identities = append(identities, identity{Pubkey: pk, Seckey: sk, Name: name})
 309  			}
 310  			i = end
 311  		}
 312  	}
 313  	if len(identities) > 0 {
 314  		activeIdx = 0
 315  	}
 316  	// Migrate v2 -> v3: re-encrypt with per-field IVs and save.
 317  	if version <= 2 && len(identities) > 0 {
 318  		vaultVersion = 3
 319  		v2IV = nil
 320  		saveVault()
 321  	}
 322  	return true
 323  }
 324  
 325  func lockVault() {
 326  	for i := range vaultKey {
 327  		vaultKey[i] = 0
 328  	}
 329  	vaultKey = nil
 330  	vaultSalt = nil
 331  	v2IV = nil
 332  	vaultOpen = false
 333  	identities = nil
 334  }
 335  
 336  func saveVault() {
 337  	kVault := string(append([]byte(nil), 's', 'm', 'e', 's', 'h', '-', 'v', 'a', 'u', 'l', 't'))
 338  	// Refuse to save if state is incomplete. Writing "{}" here would destroy
 339  	// the on-disk vault when called from a refresh-then-modify flow where
 340  	// vaultHash hadn't been rehydrated yet.
 341  	if !vaultExists || len(vaultHash) == 0 {
 342  		return
 343  	}
 344  	// Persist argon2M used at create time, so unlock uses the right param even
 345  	// if defaults change later. Legacy vaults (no field) default to argon2MLegacy.
 346  	mField := vaultArgon2M
 347  	if mField == 0 {
 348  		mField = argon2MLegacy
 349  	}
 350  	b := []byte{:512}[:0]
 351  	b = append(b, '{', '"', 'v', 'e', 'r', 's', 'i', 'o', 'n', '"', ':')
 352  	b = b | helpers.Itoa(int64(vaultVersion))
 353  	b = append(b, ',', '"', 'a', 'r', 'g', 'o', 'n', '2', 'M', '"', ':')
 354  	b = b | helpers.Itoa(int64(mField))
 355  	b = append(b, ',', '"', 'v', 'a', 'u', 'l', 't', 'H', 'a', 's', 'h', '"', ':')
 356  	b = b | helpers.JsonString(vaultHash)
 357  	b = append(b, ',', '"', 's', 'a', 'l', 't', '"', ':')
 358  	b = b | helpers.JsonString(helpers.Base64Encode(vaultSalt))
 359  	b = append(b, ',', '"', 'i', 'd', 'e', 'n', 't', 'i', 't', 'i', 'e', 's', '"', ':', '[')
 360  	for i, id := range identities {
 361  		if i > 0 {
 362  			b = append(b, ',')
 363  		}
 364  		encSk, ok := encryptField(id.Seckey)
 365  		if !ok {
 366  			encSk = id.Seckey
 367  		}
 368  		b = append(b, '{', '"', 'p', 'u', 'b', 'k', 'e', 'y', '"', ':')
 369  		b = b | helpers.JsonString(id.Pubkey)
 370  		b = append(b, ',', '"', 's', 'e', 'c', 'k', 'e', 'y', '"', ':')
 371  		b = b | helpers.JsonString(encSk)
 372  		b = append(b, ',', '"', 'n', 'a', 'm', 'e', '"', ':')
 373  		b = b | helpers.JsonString(id.Name)
 374  		b = append(b, '}')
 375  	}
 376  	b = append(b, ']', '}')
 377  	vaultRawCache = string(b)
 378  	ext.StorageSet(kVault, vaultRawCache)
 379  	if vaultOpen && ext.IsInPage() {
 380  		cacheSession()
 381  	}
 382  }
 383  
 384  func restorePlaintextVault(data string) {
 385  	kIdentities := string(append([]byte(nil), 'i', 'd', 'e', 'n', 't', 'i', 't', 'i', 'e', 's'))
 386  	ids := helpers.JsonGetValue(data, kIdentities)
 387  	if ids == "" {
 388  		return
 389  	}
 390  	kPubkey := string(append([]byte(nil), 'p', 'u', 'b', 'k', 'e', 'y'))
 391  	kSeckey := string(append([]byte(nil), 's', 'e', 'c', 'k', 'e', 'y'))
 392  	pk := helpers.JsonGetString(ids, kPubkey)
 393  	sk := helpers.JsonGetString(ids, kSeckey)
 394  	if pk == "" || sk == "" {
 395  		return
 396  	}
 397  	vaultOpen = true
 398  	vaultExists = true
 399  	vaultVersion = 0
 400  	identities = []identity{{Pubkey: pk, Seckey: sk, Name: ""}}
 401  	activeIdx = 0
 402  }
 403  
 404  const sessionCacheKey = "vault-session"
 405  
 406  func cacheSession() {
 407  	if !ext.IsInPage() || !vaultOpen || len(vaultKey) == 0 {
 408  		return
 409  	}
 410  	b := []byte(nil) | helpers.HexEncode(vaultKey)
 411  	b = append(b, ':')
 412  	b = b | helpers.HexEncode(vaultSalt)
 413  	b = append(b, ':')
 414  	b = b | helpers.Itoa(int64(vaultVersion))
 415  	b = append(b, ':')
 416  	aid := activeIdentity()
 417  	if aid != nil {
 418  		b = b | aid.Pubkey
 419  	}
 420  	kSession := string(append([]byte(nil), 'v', 'a', 'u', 'l', 't', '-', 's', 'e', 's', 's', 'i', 'o', 'n'))
 421  	ext.SessionSet(kSession, string(b))
 422  }
 423  
 424  func clearSessionCache() {
 425  	kSession := string(append([]byte(nil), 'v', 'a', 'u', 'l', 't', '-', 's', 'e', 's', 's', 'i', 'o', 'n'))
 426  	ext.SessionSet(kSession, "")
 427  }
 428  
 429  func tryRestoreSession() {
 430  	kSession := string(append([]byte(nil), 'v', 'a', 'u', 'l', 't', '-', 's', 'e', 's', 's', 'i', 'o', 'n'))
 431  	ext.SessionGet(kSession, func(val string) {
 432  		if val == "" {
 433  			return
 434  		}
 435  		var parts [6]string
 436  		n := 0
 437  		start := 0
 438  		for i := 0; i < len(val) && n < 5; i++ {
 439  			if val[i] == ':' {
 440  				parts[n] = val[start:i]
 441  				n++
 442  				start = i + 1
 443  			}
 444  		}
 445  		parts[n] = val[start:]
 446  		n++
 447  		data := vaultRawCache
 448  		if data == "" {
 449  			clearSessionCache()
 450  			return
 451  		}
 452  		// Detect format by part count and whether parts[2] is a 1-char
 453  		// version digit (v3) or a long hex salt (v2).
 454  		// v3: key:salt:version[:activePubkey] - parts[2] is 1 char
 455  		// v2: key:iv:salt:version[:activePubkey] - parts[2] is hex salt
 456  		isV3 := n >= 3 && len(parts[2]) == 1
 457  		if isV3 {
 458  			key := helpers.HexDecode(parts[0])
 459  			salt := helpers.HexDecode(parts[1])
 460  			if len(key) != 32 {
 461  				clearSessionCache()
 462  				return
 463  			}
 464  			ver := parseSessionVer(parts[2])
 465  			if !finishUnlock(data, key, nil, salt, ver) {
 466  				clearSessionCache()
 467  				return
 468  			}
 469  			if n >= 4 {
 470  				restoreActiveIdx(parts[3])
 471  			}
 472  		} else if n >= 4 {
 473  			key := helpers.HexDecode(parts[0])
 474  			iv := helpers.HexDecode(parts[1])
 475  			salt := helpers.HexDecode(parts[2])
 476  			if len(key) != 32 || len(iv) != 12 {
 477  				clearSessionCache()
 478  				return
 479  			}
 480  			ver := parseSessionVer(parts[3])
 481  			if !finishUnlock(data, key, iv, salt, ver) {
 482  				clearSessionCache()
 483  				return
 484  			}
 485  			if n >= 5 {
 486  				restoreActiveIdx(parts[4])
 487  			}
 488  		} else {
 489  			clearSessionCache()
 490  		}
 491  	})
 492  }
 493  
 494  func parseSessionVer(s string) (n int32) {
 495  	if len(s) == 1 {
 496  		if s[0] == '1' {
 497  			return 1
 498  		}
 499  		if s[0] == '2' {
 500  			return 2
 501  		}
 502  	}
 503  	return 3
 504  }
 505  
 506  // encryptFieldWith encrypts using explicit key (for export with different password).
 507  // Generates a fresh 12-byte random IV per call, prepended to ciphertext.
 508  // Output: base64(iv[12] || ciphertext || tag[16]).
 509  func encryptFieldWith(plain string, key []byte) (string, bool) {
 510  	if len(key) == 0 {
 511  		return "", false
 512  	}
 513  	iv := []byte{:12}
 514  	subtle.RandomBytes(iv)
 515  	ct := aes256gcm.Seal(key, iv, []byte(plain), nil)
 516  	if ct == nil {
 517  		return "", false
 518  	}
 519  	out := []byte{:0:12 + len(ct)}
 520  	out = out | iv
 521  	out = out | ct
 522  	return helpers.Base64Encode(out), true
 523  }
 524  
 525  // encryptField encrypts a plaintext string using vaultKey via AES-256-GCM.
 526  // Fresh random IV per call - no nonce reuse.
 527  func encryptField(plain string) (string, bool) {
 528  	if !vaultOpen || len(vaultKey) == 0 {
 529  		return "", false
 530  	}
 531  	return encryptFieldWith(plain, vaultKey)
 532  }
 533  
 534  // decryptField decrypts a base64-encoded AES-256-GCM ciphertext.
 535  // v3 format: base64(iv[12] || ciphertext || tag[16]).
 536  // v2 compat: if v2IV is set, tries old format (no prepended IV) as fallback.
 537  func decryptField(enc string) (string, bool) {
 538  	if !vaultOpen || len(vaultKey) == 0 {
 539  		return "", false
 540  	}
 541  	raw := helpers.Base64Decode(enc)
 542  	if raw == nil {
 543  		return "", false
 544  	}
 545  	// v3: first 12 bytes are per-field IV.
 546  	if len(raw) >= 12+16 {
 547  		iv := raw[:12]
 548  		ct := raw[12:]
 549  		pt, ok := aes256gcm.Open(vaultKey, iv, ct, nil)
 550  		if ok {
 551  			return string(pt), true
 552  		}
 553  	}
 554  	// v2 fallback: shared IV from vault JSON.
 555  	if len(v2IV) == 12 {
 556  		pt, ok := aes256gcm.Open(vaultKey, v2IV, raw, nil)
 557  		if ok {
 558  			return string(pt), true
 559  		}
 560  	}
 561  	return "", false
 562  }
 563  
 564  func passwordHash(pw string) (s string) {
 565  	return subtle.SHA256Hex([]byte(pw))
 566  }
 567  
 568  func unlockLegacy(data, password string) (ok bool) {
 569  	sepIdx := -1
 570  	for i := 0; i < len(data); i++ {
 571  		if data[i] == ':' {
 572  			sepIdx = i
 573  			break
 574  		}
 575  	}
 576  	if sepIdx < 1 {
 577  		return false
 578  	}
 579  	iv := helpers.HexDecode(data[:sepIdx])
 580  	ct := helpers.HexDecode(data[sepIdx+1:])
 581  	if iv == nil || ct == nil {
 582  		return false
 583  	}
 584  	key := legacyDeriveKey(password)
 585  	pt := subtle.AESCBCDecrypt(key[:], iv, ct)
 586  	if len(pt) == 0 {
 587  		return false
 588  	}
 589  	parseLegacyIdentities(string(pt))
 590  	newSalt := []byte{:32}
 591  	subtle.RandomBytes(newSalt)
 592  	hash := passwordHash(password)
 593  	if hash == "" {
 594  		return true
 595  	}
 596  	newKey := subtle.Argon2idDeriveKey(password, newSalt, argon2T, argon2MNew, argon2P, argon2DKLen)
 597  	if len(newKey) == 0 {
 598  		return true
 599  	}
 600  	vaultKey = newKey
 601  	vaultSalt = newSalt
 602  	vaultHash = hash
 603  	vaultVersion = 3
 604  	vaultArgon2M = argon2MNew
 605  	vaultOpen = true
 606  	vaultExists = true
 607  	saveVault()
 608  	return true
 609  }
 610  
 611  // legacyDeriveKey derives a 32-byte key via 100000 rounds of SHA-256 over "smesh-vault-salt:" | password.
 612  func legacyDeriveKey(password string) (v [32]byte) {
 613  	saltPrefix := append([]byte(nil), 's', 'm', 'e', 's', 'h', '-', 'v', 'a', 'u', 'l', 't', '-', 's', 'a', 'l', 't', ':')
 614  	saltPrefix = saltPrefix | password
 615  	h := sha256.Sum(saltPrefix)
 616  	for i := 0; i < 100000; i++ {
 617  		h = sha256.Sum(h[:])
 618  	}
 619  	return h
 620  }
 621  
 622  // parseLegacyIdentities parses plaintext JSON array of identities from old format.
 623  func parseLegacyIdentities(s string) {
 624  	identities = nil
 625  	activeIdx = -1
 626  	i := 0
 627  	for i < len(s) && s[i] != '[' {
 628  		i++
 629  	}
 630  	i++
 631  	for i < len(s) {
 632  		for i < len(s) && s[i] != '{' && s[i] != ']' {
 633  			i++
 634  		}
 635  		if i >= len(s) || s[i] == ']' {
 636  			break
 637  		}
 638  		end := i + 1
 639  		depth := 1
 640  		for end < len(s) && depth > 0 {
 641  			if s[end] == '{' {
 642  				depth++
 643  			} else if s[end] == '}' {
 644  				depth--
 645  			} else if s[end] == '"' {
 646  				end++
 647  				for end < len(s) && s[end] != '"' {
 648  					if s[end] == '\\' {
 649  						end++
 650  					}
 651  					end++
 652  				}
 653  			}
 654  			end++
 655  		}
 656  		obj := s[i:end]
 657  		kPubkey := string(append([]byte(nil), 'p', 'u', 'b', 'k', 'e', 'y'))
 658  		kPublicKey := string(append([]byte(nil), 'p', 'u', 'b', 'l', 'i', 'c', 'K', 'e', 'y'))
 659  		kPub := string(append([]byte(nil), 'p', 'u', 'b'))
 660  		kSeckey := string(append([]byte(nil), 's', 'e', 'c', 'k', 'e', 'y'))
 661  		kPrivkey := string(append([]byte(nil), 'p', 'r', 'i', 'v', 'k', 'e', 'y'))
 662  		kSecretKey := string(append([]byte(nil), 's', 'e', 'c', 'r', 'e', 't', 'K', 'e', 'y'))
 663  		kSec := string(append([]byte(nil), 's', 'e', 'c'))
 664  		kKey := string(append([]byte(nil), 'k', 'e', 'y'))
 665  		kName := string(append([]byte(nil), 'n', 'a', 'm', 'e'))
 666  		kNick := string(append([]byte(nil), 'n', 'i', 'c', 'k'))
 667  		kLabel := string(append([]byte(nil), 'l', 'a', 'b', 'e', 'l'))
 668  		pk := helpers.JsonGetString(obj, kPubkey)
 669  		if pk == "" {
 670  			pk = helpers.JsonGetString(obj, kPublicKey)
 671  		}
 672  		if pk == "" {
 673  			pk = helpers.JsonGetString(obj, kPub)
 674  		}
 675  		sk := helpers.JsonGetString(obj, kSeckey)
 676  		if sk == "" {
 677  			sk = helpers.JsonGetString(obj, kPrivkey)
 678  		}
 679  		if sk == "" {
 680  			sk = helpers.JsonGetString(obj, kSecretKey)
 681  		}
 682  		if sk == "" {
 683  			sk = helpers.JsonGetString(obj, kSec)
 684  		}
 685  		if sk == "" {
 686  			sk = helpers.JsonGetString(obj, kKey)
 687  		}
 688  		nm := helpers.JsonGetString(obj, kName)
 689  		if nm == "" {
 690  			nm = helpers.JsonGetString(obj, kNick)
 691  		}
 692  		if nm == "" {
 693  			nm = helpers.JsonGetString(obj, kLabel)
 694  		}
 695  		// nsec decode
 696  		kNsec1 := string(append([]byte(nil), 'n', 's', 'e', 'c', '1'))
 697  		if len(sk) >= 5 && sk[:5] == kNsec1 {
 698  			skBytes := helpers.DecodeNsec(sk)
 699  			if skBytes != nil {
 700  				sk = helpers.HexEncode(skBytes)
 701  			}
 702  		}
 703  		// derive pubkey if absent
 704  		if pk == "" && sk != "" {
 705  			skBytes := helpers.HexDecode(sk)
 706  			if skBytes != nil {
 707  				dpk, ok := schnorr.PubKeyFromSecKey(skBytes)
 708  				if ok {
 709  					pk = helpers.HexEncode(dpk)
 710  				}
 711  			}
 712  		}
 713  		if pk != "" && sk != "" {
 714  			identities = append(identities, identity{Pubkey: pk, Seckey: sk, Name: nm})
 715  		}
 716  		i = end
 717  	}
 718  	if len(identities) > 0 {
 719  		activeIdx = 0
 720  	}
 721  }
 722