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