vault.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  // Vault: compatible with Plebeian/Smesh signer format.
  11  // V2: Argon2id + AES-256-GCM, per-field encryption, JSON envelope.
  12  // V1: PBKDF2 + AES-256-GCM, same structure (auto-migrated on unlock).
  13  //
  14  // Stored in browser.storage.local as JSON:
  15  // {
  16  //   "version": 2,
  17  //   "iv": "base64(12 bytes)",
  18  //   "salt": "base64(32 bytes)",       // v2 only
  19  //   "vaultHash": "hex(SHA-256(pw))",
  20  //   "identities": [{id,nick,createdAt,privkey}],  // each field AES-GCM encrypted base64
  21  //   "selectedIdentityId": "encrypted" | null,
  22  //   "permissions": [],
  23  //   "relays": []
  24  // }
  25  
  26  const (
  27  	vaultStorageKey = "smesh-vault"
  28  	pbkdf2Salt      = "3e7cdebd-3b4c-4125-a18c-05750cad8ec3"
  29  	pbkdf2Iters     = 1000
  30  	argon2T         = 8
  31  	argon2M         = 262144 // 256 MB
  32  	argon2P         = 4
  33  	argon2DKLen     = 32
  34  )
  35  
  36  type identity struct {
  37  	Pubkey string // hex (derived from Seckey)
  38  	Seckey string // hex (plaintext in memory when unlocked)
  39  	Name   string
  40  }
  41  
  42  type encryptedID struct {
  43  	privkey string
  44  	nick    string
  45  }
  46  
  47  var (
  48  	vaultKey      []byte // 32-byte derived key, nil when locked
  49  	vaultIV       []byte // 12-byte shared IV
  50  	vaultSalt     []byte // 32-byte salt (v2 only, nil for v1)
  51  	vaultHash     string // hex SHA-256 of password
  52  	vaultVersion  int    // 1 or 2
  53  	vaultOpen     bool
  54  	identities    []identity
  55  	activeIdx     int
  56  	vaultExists   bool
  57  	vaultRawCache string // cached JSON from storage
  58  )
  59  
  60  func log(msg string) {
  61  	ext.ConsoleLog(msg)
  62  }
  63  
  64  func loadVault() {
  65  	ext.StorageGet(vaultStorageKey, func(data string) {
  66  		vaultRawCache = data
  67  		vaultExists = data != ""
  68  		if data == "" {
  69  			log("loadVault: no vault in storage")
  70  		} else if len(data) > 40 {
  71  			log("loadVault: loaded, first 40 chars: " + data[:40])
  72  		} else {
  73  			log("loadVault: loaded, data: " + data)
  74  		}
  75  	})
  76  }
  77  
  78  // passwordHash computes lowercase hex SHA-256 of the password via native crypto.subtle.
  79  func passwordHash(pw string, fn func(string)) {
  80  	subtle.SHA256Hex([]byte(pw), fn)
  81  }
  82  
  83  // createVault creates a new v2 vault with Argon2id.
  84  func createVault(password string, done func(bool)) {
  85  	passwordHash(password, func(hash string) {
  86  		if hash == "" {
  87  			done(false)
  88  			return
  89  		}
  90  
  91  		// Generate random salt (32 bytes) and IV (12 bytes).
  92  		salt := []byte{:32}
  93  		iv := []byte{:12}
  94  		subtle.RandomBytes(salt)
  95  		subtle.RandomBytes(iv)
  96  
  97  		subtle.Argon2idDeriveKey(password, salt, argon2T, argon2M, argon2P, argon2DKLen, func(key []byte) {
  98  			if len(key) == 0 {
  99  				done(false)
 100  				return
 101  			}
 102  			vaultKey = key
 103  			vaultIV = iv
 104  			vaultSalt = salt
 105  			vaultHash = hash
 106  			vaultVersion = 2
 107  			vaultOpen = true
 108  			identities = nil
 109  			activeIdx = -1
 110  			vaultExists = true
 111  			saveVault(func() {
 112  				done(true)
 113  			})
 114  		})
 115  	})
 116  }
 117  
 118  // unlockVault unlocks an existing vault.
 119  func unlockVault(password string, done func(bool)) {
 120  	data := vaultRawCache
 121  	log("unlockVault: data len=" + itoa(len(data)))
 122  	if data == "" {
 123  		log("unlockVault: no data")
 124  		done(false)
 125  		return
 126  	}
 127  
 128  	// Detect format: legacy "hex:hex" (AES-CBC) vs JSON envelope.
 129  	if len(data) > 0 && data[0] != '{' {
 130  		log("unlockVault: detected legacy format")
 131  		unlockLegacy(data, password, done)
 132  		return
 133  	}
 134  
 135  	// Quick password check via vaultHash (async — uses native crypto.subtle).
 136  	log("unlockVault: JSON format detected")
 137  	storedHash := helpers.JsonGetString(data, "vaultHash")
 138  	if storedHash == "" {
 139  		log("unlockVault: no vaultHash in data")
 140  		done(false)
 141  		return
 142  	}
 143  	passwordHash(password, func(computed string) {
 144  		log("unlockVault: stored=" + storedHash + " computed=" + computed)
 145  		if computed != storedHash {
 146  			log("unlockVault: hash mismatch")
 147  			done(false)
 148  			return
 149  		}
 150  		log("unlockVault: hash OK")
 151  
 152  		// Parse version and IV.
 153  		ivB64 := helpers.JsonGetString(data, "iv")
 154  		iv := helpers.Base64Decode(ivB64)
 155  		if len(iv) != 12 {
 156  			log("unlockVault: bad IV length")
 157  			done(false)
 158  			return
 159  		}
 160  
 161  		// Detect v2 by presence of salt.
 162  		saltB64 := helpers.JsonGetString(data, "salt")
 163  		isV2 := saltB64 != ""
 164  
 165  		if isV2 {
 166  			salt := helpers.Base64Decode(saltB64)
 167  			if len(salt) == 0 {
 168  				done(false)
 169  				return
 170  			}
 171  			log("unlockVault: v2, deriving key with Argon2id...")
 172  			subtle.Argon2idDeriveKey(password, salt, argon2T, argon2M, argon2P, argon2DKLen, func(key []byte) {
 173  				if len(key) == 0 {
 174  					log("unlockVault: Argon2id failed")
 175  					done(false)
 176  					return
 177  				}
 178  				log("unlockVault: key derived, decrypting...")
 179  				finishUnlock(data, key, iv, salt, 2, password, done)
 180  			})
 181  		} else {
 182  			log("unlockVault: v1, deriving key with PBKDF2...")
 183  			subtle.PBKDF2DeriveKey(password, []byte(pbkdf2Salt), pbkdf2Iters, func(key []byte) {
 184  				if len(key) == 0 {
 185  					log("unlockVault: PBKDF2 failed")
 186  					done(false)
 187  					return
 188  				}
 189  				log("unlockVault: key derived, decrypting...")
 190  				finishUnlock(data, key, iv, nil, 1, password, done)
 191  			})
 192  		}
 193  	})
 194  }
 195  
 196  // unlockLegacy handles the old "hex(IV):hex(ciphertext)" AES-CBC format.
 197  // Derives key via iterated SHA-256 and migrates to the new format on success.
 198  func unlockLegacy(data, password string, done func(bool)) {
 199  	sepIdx := -1
 200  	for i := 0; i < len(data); i++ {
 201  		if data[i] == ':' {
 202  			sepIdx = i
 203  			break
 204  		}
 205  	}
 206  	if sepIdx < 1 {
 207  		log("unlockLegacy: no separator found")
 208  		done(false)
 209  		return
 210  	}
 211  	log("unlockLegacy: sep at " + itoa(sepIdx) + ", iv hex len=" + itoa(sepIdx) + ", ct hex len=" + itoa(len(data)-sepIdx-1))
 212  	iv := helpers.HexDecode(data[:sepIdx])
 213  	ct := helpers.HexDecode(data[sepIdx+1:])
 214  	if iv == nil {
 215  		log("unlockLegacy: iv hex decode failed")
 216  		done(false)
 217  		return
 218  	}
 219  	if ct == nil {
 220  		log("unlockLegacy: ct hex decode failed")
 221  		done(false)
 222  		return
 223  	}
 224  	log("unlockLegacy: iv len=" + itoa(len(iv)) + ", ct len=" + itoa(len(ct)))
 225  
 226  	// Old key derivation: iterated SHA-256.
 227  	log("unlockLegacy: deriving key (100k SHA-256 iterations)...")
 228  	key := legacyDeriveKey(password)
 229  	log("unlockLegacy: key derived, first 4 bytes: " + helpers.HexEncode(key[:4]))
 230  	subtle.AESCBCDecrypt(key[:], iv, ct, func(pt []byte) {
 231  		log("unlockLegacy: AESCBCDecrypt returned " + itoa(len(pt)) + " bytes")
 232  		if len(pt) == 0 {
 233  			log("unlockLegacy: decryption failed (empty result)")
 234  			done(false)
 235  			return
 236  		}
 237  		if len(pt) > 60 {
 238  			log("unlockLegacy: plaintext first 60: " + string(pt[:60]))
 239  		} else {
 240  			log("unlockLegacy: plaintext: " + string(pt))
 241  		}
 242  		vaultOpen = true
 243  		parseLegacyIdentities(string(pt))
 244  		log("unlockLegacy: parsed " + itoa(len(identities)) + " identities, migrating...")
 245  
 246  		// Migrate to new format.
 247  		migrateV1ToV2(password, func(ok bool) {
 248  			log("unlockLegacy: migration done, ok=" + boolStr(ok))
 249  			done(true)
 250  		})
 251  	})
 252  }
 253  
 254  // legacyDeriveKey derives a 32-byte key using the old iterated SHA-256 scheme.
 255  func legacyDeriveKey(password string) [32]byte {
 256  	hSlice := schnorr.SHA256Sum([]byte("smesh-vault-salt:" + password))
 257  	var h [32]byte
 258  	copy(h[:], hSlice)
 259  	for i := 0; i < 100000; i++ {
 260  		hSlice = schnorr.SHA256Sum(h[:])
 261  		copy(h[:], hSlice)
 262  	}
 263  	return h
 264  }
 265  
 266  // parseLegacyIdentities parses the old plaintext format: [{pubkey,seckey,name},...]
 267  func parseLegacyIdentities(s string) {
 268  	identities = nil
 269  	activeIdx = -1
 270  	i := 0
 271  	for i < len(s) && s[i] != '[' {
 272  		i++
 273  	}
 274  	i++
 275  	for i < len(s) {
 276  		for i < len(s) && s[i] != '{' && s[i] != ']' {
 277  			i++
 278  		}
 279  		if i >= len(s) || s[i] == ']' {
 280  			break
 281  		}
 282  		end := i + 1
 283  		depth := 1
 284  		for end < len(s) && depth > 0 {
 285  			if s[end] == '{' {
 286  				depth++
 287  			} else if s[end] == '}' {
 288  				depth--
 289  			} else if s[end] == '"' {
 290  				end++
 291  				for end < len(s) && s[end] != '"' {
 292  					if s[end] == '\\' {
 293  						end++
 294  					}
 295  					end++
 296  				}
 297  			}
 298  			end++
 299  		}
 300  		obj := s[i:end]
 301  		pk := helpers.JsonGetString(obj, "pubkey")
 302  		sk := helpers.JsonGetString(obj, "seckey")
 303  		nm := helpers.JsonGetString(obj, "name")
 304  		if pk != "" && sk != "" {
 305  			identities = append(identities, identity{Pubkey: pk, Seckey: sk, Name: nm})
 306  		}
 307  		i = end
 308  	}
 309  	if len(identities) > 0 {
 310  		activeIdx = 0
 311  	}
 312  }
 313  
 314  // finishUnlock decrypts vault fields and populates memory state.
 315  // If v1, triggers auto-migration to v2.
 316  func finishUnlock(data string, key, iv, salt []byte, version int, password string, done func(bool)) {
 317  	vaultKey = key
 318  	vaultIV = iv
 319  	vaultSalt = salt
 320  	vaultHash = helpers.JsonGetString(data, "vaultHash")
 321  	vaultVersion = version
 322  	vaultOpen = true
 323  	identities = nil
 324  	activeIdx = -1
 325  
 326  	// Parse and decrypt identities.
 327  	idList := helpers.JsonGetValue(data, "identities")
 328  	decryptIdentities(idList, key, iv, func(ok bool) {
 329  		if !ok {
 330  			vaultOpen = false
 331  			vaultKey = nil
 332  			done(false)
 333  			return
 334  		}
 335  
 336  		// Load HD fields (mnemonic, hdNextAccount) if present.
 337  		loadHDFields(data, key, iv, func() {
 338  			// Decrypt selectedIdentityId if present.
 339  			selEnc := helpers.JsonGetString(data, "selectedIdentityId")
 340  			if selEnc != "" {
 341  				decryptField(selEnc, key, iv, func(selID string) {
 342  					selectIdentityByID(selID)
 343  					if version == 1 {
 344  						migrateV1ToV2(password, done)
 345  					} else {
 346  						done(true)
 347  					}
 348  				})
 349  			} else {
 350  				if len(identities) > 0 {
 351  					activeIdx = 0
 352  				}
 353  				if version == 1 {
 354  					migrateV1ToV2(password, done)
 355  				} else {
 356  					done(true)
 357  				}
 358  			}
 359  		})
 360  	})
 361  }
 362  
 363  func selectIdentityByID(id string) {
 364  	_ = id
 365  	if len(identities) > 0 && activeIdx < 0 {
 366  		activeIdx = 0
 367  	}
 368  }
 369  
 370  // migrateV1ToV2 re-encrypts vault from PBKDF2 to Argon2id.
 371  func migrateV1ToV2(password string, done func(bool)) {
 372  	salt := []byte{:32}
 373  	iv := []byte{:12}
 374  	subtle.RandomBytes(salt)
 375  	subtle.RandomBytes(iv)
 376  
 377  	subtle.Argon2idDeriveKey(password, salt, argon2T, argon2M, argon2P, argon2DKLen, func(key []byte) {
 378  		if len(key) == 0 {
 379  			// Migration failed, but v1 unlock succeeded.
 380  			done(true)
 381  			return
 382  		}
 383  		vaultKey = key
 384  		vaultIV = iv
 385  		vaultSalt = salt
 386  		vaultVersion = 2
 387  		saveVault(func() {
 388  			done(true)
 389  		})
 390  	})
 391  }
 392  
 393  func lockVault() {
 394  	vaultKey = nil
 395  	vaultIV = nil
 396  	vaultSalt = nil
 397  	vaultOpen = false
 398  	identities = nil
 399  	activeIdx = -1
 400  	lockHD()
 401  }
 402  
 403  // --- Per-field AES-GCM encryption ---
 404  
 405  func encryptField(plaintext string, key, iv []byte, fn func(string)) {
 406  	if plaintext == "" {
 407  		fn("")
 408  		return
 409  	}
 410  	subtle.AESGCMEncrypt(key, iv, []byte(plaintext), func(ct []byte) {
 411  		if len(ct) == 0 {
 412  			fn("")
 413  			return
 414  		}
 415  		fn(helpers.Base64Encode(ct))
 416  	})
 417  }
 418  
 419  func decryptField(b64 string, key, iv []byte, fn func(string)) {
 420  	if b64 == "" {
 421  		fn("")
 422  		return
 423  	}
 424  	ct := helpers.Base64Decode(b64)
 425  	if ct == nil {
 426  		fn("")
 427  		return
 428  	}
 429  	subtle.AESGCMDecrypt(key, iv, ct, func(pt []byte) {
 430  		fn(string(pt))
 431  	})
 432  }
 433  
 434  // --- Identity encryption/decryption ---
 435  
 436  // decryptIdentities parses the JSON identity array and decrypts each field.
 437  // Calls done(true) when all identities are decrypted.
 438  func decryptIdentities(listJSON string, key, iv []byte, done func(bool)) {
 439  	if listJSON == "" || listJSON == "[]" {
 440  		done(true)
 441  		return
 442  	}
 443  
 444  	var enc []encryptedID
 445  
 446  	i := 0
 447  	for i < len(listJSON) && listJSON[i] != '[' {
 448  		i++
 449  	}
 450  	i++
 451  	for i < len(listJSON) {
 452  		for i < len(listJSON) && listJSON[i] != '{' && listJSON[i] != ']' {
 453  			i++
 454  		}
 455  		if i >= len(listJSON) || listJSON[i] == ']' {
 456  			break
 457  		}
 458  		end := i + 1
 459  		depth := 1
 460  		for end < len(listJSON) && depth > 0 {
 461  			if listJSON[end] == '{' {
 462  				depth++
 463  			} else if listJSON[end] == '}' {
 464  				depth--
 465  			} else if listJSON[end] == '"' {
 466  				end++
 467  				for end < len(listJSON) && listJSON[end] != '"' {
 468  					if listJSON[end] == '\\' {
 469  						end++
 470  					}
 471  					end++
 472  				}
 473  			}
 474  			end++
 475  		}
 476  		obj := listJSON[i:end]
 477  		enc = append(enc, encryptedID{
 478  			privkey: helpers.JsonGetString(obj, "privkey"),
 479  			nick:    helpers.JsonGetString(obj, "nick"),
 480  		})
 481  		i = end
 482  	}
 483  
 484  	if len(enc) == 0 {
 485  		done(true)
 486  		return
 487  	}
 488  
 489  	// Decrypt sequentially (async chain).
 490  	decryptNext(enc, 0, key, iv, done)
 491  }
 492  
 493  func decryptNext(enc []encryptedID, idx int, key, iv []byte, done func(bool)) {
 494  	if idx >= len(enc) {
 495  		done(true)
 496  		return
 497  	}
 498  	e := enc[idx]
 499  	decryptField(e.privkey, key, iv, func(skHex string) {
 500  		if skHex == "" {
 501  			// Skip invalid entries.
 502  			decryptNext(enc, idx+1, key, iv, done)
 503  			return
 504  		}
 505  		decryptField(e.nick, key, iv, func(nick string) {
 506  			// Derive pubkey from secret key.
 507  			skBytes := helpers.HexDecode(skHex)
 508  			if skBytes == nil {
 509  				decryptNext(enc, idx+1, key, iv, done)
 510  				return
 511  			}
 512  			// Use schnorr to get pubkey.
 513  			pk, ok := schnorrPubFromSec(skBytes)
 514  			if !ok {
 515  				decryptNext(enc, idx+1, key, iv, done)
 516  				return
 517  			}
 518  			identities = append(identities, identity{
 519  				Pubkey: helpers.HexEncode(pk),
 520  				Seckey: skHex,
 521  				Name:   nick,
 522  			})
 523  			decryptNext(enc, idx+1, key, iv, done)
 524  		})
 525  	})
 526  }
 527  
 528  // schnorrPubFromSec wraps the schnorr bridge to derive pubkey from seckey.
 529  func schnorrPubFromSec(sk []byte) ([]byte, bool) {
 530  	return schnorr.PubKeyFromSecKey(sk)
 531  }
 532  
 533  // --- Vault serialization ---
 534  
 535  // saveVault encrypts and saves the vault to storage.
 536  func saveVault(done func()) {
 537  	if !vaultOpen {
 538  		if done != nil {
 539  			done()
 540  		}
 541  		return
 542  	}
 543  
 544  	// Encrypt all identities, then build JSON.
 545  	encryptAllIdentities(vaultKey, vaultIV, func(idJSON string) {
 546  		// Build vault JSON.
 547  		s := "{\"version\":" + itoa(vaultVersion) +
 548  			",\"iv\":" + helpers.JsonString(helpers.Base64Encode(vaultIV)) +
 549  			",\"vaultHash\":" + helpers.JsonString(vaultHash)
 550  
 551  		if vaultVersion >= 2 && vaultSalt != nil {
 552  			s += ",\"salt\":" + helpers.JsonString(helpers.Base64Encode(vaultSalt))
 553  		}
 554  
 555  		s += ",\"identities\":" + idJSON
 556  		s += ",\"permissions\":[]"
 557  		s += ",\"relays\":[]"
 558  		s += ",\"selectedIdentityId\":null"
 559  		s += "}"
 560  
 561  		vaultRawCache = s
 562  		ext.StorageSet(vaultStorageKey, s)
 563  		if done != nil {
 564  			done()
 565  		}
 566  	})
 567  }
 568  
 569  func encryptAllIdentities(key, iv []byte, fn func(string)) {
 570  	if len(identities) == 0 {
 571  		fn("[]")
 572  		return
 573  	}
 574  	encryptIDAt(key, iv, 0, "[", fn)
 575  }
 576  
 577  func encryptIDAt(key, iv []byte, idx int, acc string, fn func(string)) {
 578  	if idx >= len(identities) {
 579  		fn(acc + "]")
 580  		return
 581  	}
 582  	id := identities[idx]
 583  	if idx > 0 {
 584  		acc += ","
 585  	}
 586  	// Encrypt privkey and nick.
 587  	encryptField(id.Seckey, key, iv, func(encSK string) {
 588  		encryptField(id.Name, key, iv, func(encNick string) {
 589  			// Generate a stable ID from pubkey.
 590  			encryptField(id.Pubkey, key, iv, func(encID string) {
 591  				obj := "{\"id\":" + helpers.JsonString(encID) +
 592  					",\"nick\":" + helpers.JsonString(encNick) +
 593  					",\"createdAt\":" + helpers.JsonString("") +
 594  					",\"privkey\":" + helpers.JsonString(encSK) + "}"
 595  				encryptIDAt(key, iv, idx+1, acc+obj, fn)
 596  			})
 597  		})
 598  	})
 599  }
 600  
 601  func boolStr(b bool) string {
 602  	if b {
 603  		return "true"
 604  	}
 605  	return "false"
 606  }
 607  
 608  func itoa(n int) string {
 609  	if n == 0 {
 610  		return "0"
 611  	}
 612  	s := ""
 613  	for n > 0 {
 614  		s = string(rune('0'+n%10)) + s
 615  		n /= 10
 616  	}
 617  	return s
 618  }
 619  
 620  func activeIdentity() *identity {
 621  	if !vaultOpen || activeIdx < 0 || activeIdx >= len(identities) {
 622  		return nil
 623  	}
 624  	return &identities[activeIdx]
 625  }
 626