package main import ( "git.smesh.lol/nostr/pkg/crypto/aes256gcm" "crypto/sha256" "git.smesh.lol/musiquay/web/common/helpers" "git.smesh.lol/musiquay/web/common/jsbridge/ext" "git.smesh.lol/musiquay/web/common/jsbridge/schnorr" "git.smesh.lol/musiquay/web/common/jsbridge/subtle" ) // Vault: Argon2id (v2) or PBKDF2 (v1, auto-migrated) + AES-256-GCM field // encryption. Compatible with the Plebeian/Smesh signer format. // Stored in browser.storage.local under vaultStorageKey. const ( vaultStorageKey = "musiquay-vault" permsStorageKey = "musiquay-permissions" pbkdf2Salt = "3e7cdebd-3b4c-4125-a18c-05750cad8ec3" pbkdf2Iters = 1000 argon2T = 3 // argon2MLegacy = original 64 MB. Existing vaults use this; new vaults use // argon2MNew. 64 MB allocates a 8MB block that can trip TinyGo GC stack // overflow on mobile WASM (limited linear memory, narrow GC stack). argon2MLegacy = 65536 argon2MNew = 4096 // 4 MB - mobile-safe, still ~4 GPU-min/guess argon2P = 1 argon2DKLen = 32 ) type identity struct { Pubkey string // hex Seckey string // hex, plaintext in memory while unlocked Name string } func loadVault() { ext.StorageGet(vaultStorageKey, func(data string) { signSt.vaultRawCache = data signSt.vaultExists = data != "" if data == "" { ext.SignalReady() return } // Use heap-built key to avoid literal address relocation bug in wasm32. kVersion := string(push([]byte(nil), 'v', 'e', 'r', 's', 'i', 'o', 'n')) ver := helpers.JsonGetValue(data, kVersion) if len(ver) == 1 && ver[0] == '0' { restorePlaintextVault(data) ext.SignalReady() return } if ext.IsInPage() { tryRestoreSession() } ext.SignalReady() }) } func activeIdentity() (p *identity) { if !signSt.vaultOpen || signSt.activeIdx < 0 || signSt.activeIdx >= len(signSt.identities) { return nil } return &signSt.identities[signSt.activeIdx] } func restoreActiveIdx(pk string) { if pk == "" { return } for i, id := range signSt.identities { if id.Pubkey == pk { signSt.activeIdx = i return } } } func createVault(password string) (ok bool) { hash := passwordHash(password) if hash == "" { return false } salt := []byte{:32} subtle.RandomBytes(salt) key := subtle.Argon2idDeriveKey(password, salt, argon2T, argon2MNew, argon2P, argon2DKLen) if len(key) == 0 { return false } signSt.vaultKey = key signSt.vaultSalt = salt signSt.vaultHash = hash signSt.vaultVersion = 3 signSt.vaultArgon2M = argon2MNew signSt.vaultOpen = true signSt.identities = nil signSt.activeIdx = -1 signSt.vaultExists = true saveVault() return true } func unlockVault(password string) (ok bool) { signSt.lastUnlockErr = "" data := signSt.vaultRawCache if data == "" { signSt.lastUnlockErr = "no-vault" return false } if len(data) > 0 && data[0] != '{' { return unlockLegacy(data, password) } kVaultHash := string(push([]byte(nil), 'v', 'a', 'u', 'l', 't', 'H', 'a', 's', 'h')) kIV := string(push([]byte(nil), 'i', 'v')) kSalt := string(push([]byte(nil), 's', 'a', 'l', 't')) kVersion := string(push([]byte(nil), 'v', 'e', 'r', 's', 'i', 'o', 'n')) kArgon2M := string(push([]byte(nil), 'a', 'r', 'g', 'o', 'n', '2', 'M')) storedHash := helpers.JsonGetString(data, kVaultHash) if storedHash == "" { signSt.lastUnlockErr = "no-hash" return false } computed := passwordHash(password) signSt.vaultHash = storedHash if computed != storedHash { // Include hash prefixes so the diagnostic distinguishes: // - typo (different prefixes, expected for a real typo) // - corrupted save (computed stable across attempts, stored differs) // - non-deterministic hash on WASM (computed differs across attempts) cp := computed if len(cp) > 12 { cp = cp[:12] } sp := storedHash if len(sp) > 12 { sp = sp[:12] } signSt.lastUnlockErr = "wrong-password computed=" | cp | " stored=" | sp | " pwlen=" | helpers.Itoa(int64(len(password))) return false } ver := helpers.JsonGetValue(data, kVersion) storedVersion := 2 if len(ver) == 1 && ver[0] == '3' { storedVersion = 3 } // v2 vaults have a shared IV; v3 has per-field IVs (no "iv" in JSON). var legacyIV []byte if storedVersion <= 2 { ivB64 := helpers.JsonGetString(data, kIV) legacyIV = helpers.Base64Decode(ivB64) if len(legacyIV) != 12 { signSt.lastUnlockErr = "bad-iv" return false } } // Read argon2M param. Missing field = legacy vault (m=65536). argMRaw := helpers.JsonGetValue(data, kArgon2M) argM := argon2MLegacy if argMRaw != "" { v := 0 for i := 0; i < len(argMRaw); i++ { c := argMRaw[i] if c >= '0' && c <= '9' { v = v*10 + int32(c-'0') } else { v = 0 break } } if v > 0 { argM = v } } saltB64 := helpers.JsonGetString(data, kSalt) if saltB64 != "" { salt := helpers.Base64Decode(saltB64) argonKey := subtle.Argon2idDeriveKey(password, salt, argon2T, argM, argon2P, argon2DKLen) if len(argonKey) == 0 { signSt.lastUnlockErr = "kdf-failed" return false } if !finishUnlock(data, argonKey, legacyIV, salt, storedVersion) { signSt.lastUnlockErr = "decrypt-failed" return false } signSt.vaultArgon2M = argM return true } key := subtle.PBKDF2DeriveKey(password, []byte(pbkdf2Salt), pbkdf2Iters) if len(key) == 0 { signSt.lastUnlockErr = "kdf-failed" return false } if !finishUnlock(data, key, legacyIV, nil, 1) { signSt.lastUnlockErr = "decrypt-failed" return false } return true } func finishUnlock(data string, key, legacyIV, salt []byte, version int32) (ok bool) { signSt.vaultKey = key signSt.vaultSalt = salt signSt.vaultVersion = version signSt.v2IV = legacyIV signSt.vaultOpen = true // Restore signSt.vaultHash and signSt.vaultArgon2M from stored JSON so a subsequent // saveVault doesn't see len(signSt.vaultHash)==0 and wipe storage with "{}". // On a fresh page load, globals reset to zero and only data fields are // rehydrated by callers - these two were missed before. kVaultHashF := string(push([]byte(nil), 'v', 'a', 'u', 'l', 't', 'H', 'a', 's', 'h')) if h := helpers.JsonGetString(data, kVaultHashF); h != "" { signSt.vaultHash = h } kArgon2MF := string(push([]byte(nil), 'a', 'r', 'g', 'o', 'n', '2', 'M')) if signSt.vaultArgon2M == 0 { raw := helpers.JsonGetValue(data, kArgon2MF) v := 0 for i := 0; i < len(raw); i++ { c := raw[i] if c < '0' || c > '9' { v = 0; break } v = v*10 + int32(c-'0') } if v > 0 { signSt.vaultArgon2M = v } else { signSt.vaultArgon2M = argon2MLegacy } } kIdentities := string(push([]byte(nil), 'i', 'd', 'e', 'n', 't', 'i', 't', 'i', 'e', 's')) idsRaw := helpers.JsonGetValue(data, kIdentities) signSt.identities = nil signSt.activeIdx = -1 if idsRaw != "" { kPubkey := string(push([]byte(nil), 'p', 'u', 'b', 'k', 'e', 'y')) kSeckey := string(push([]byte(nil), 's', 'e', 'c', 'k', 'e', 'y')) kName := string(push([]byte(nil), 'n', 'a', 'm', 'e')) i := 0 for i < len(idsRaw) && idsRaw[i] != '[' { i++ } i++ for i < len(idsRaw) { for i < len(idsRaw) && idsRaw[i] != '{' && idsRaw[i] != ']' { i++ } if i >= len(idsRaw) || idsRaw[i] == ']' { break } end := i + 1 depth := 1 for end < len(idsRaw) && depth > 0 { if idsRaw[end] == '{' { depth++ } else if idsRaw[end] == '}' { depth-- } else if idsRaw[end] == '"' { end++ for end < len(idsRaw) && idsRaw[end] != '"' { if idsRaw[end] == '\\' { end++ } end++ } } end++ } obj := idsRaw[i:end] pk := helpers.JsonGetString(obj, kPubkey) encSk := helpers.JsonGetString(obj, kSeckey) name := helpers.JsonGetString(obj, kName) if pk != "" { sk := encSk decryptOK := true if version >= 2 && encSk != "" { if plain, ok2 := decryptField(encSk); ok2 { sk = plain } else { decryptOK = false } } if !decryptOK { // Bail. Caller surfaces this as decrypt-failed. lockVault() return false } signSt.identities = push(signSt.identities, identity{Pubkey: pk, Seckey: sk, Name: name}) } i = end } } if len(signSt.identities) > 0 { signSt.activeIdx = 0 } // Migrate v2 -> v3: re-encrypt with per-field IVs and save. if version <= 2 && len(signSt.identities) > 0 { signSt.vaultVersion = 3 signSt.v2IV = nil saveVault() } return true } func lockVault() { for i := range signSt.vaultKey { signSt.vaultKey[i] = 0 } signSt.vaultKey = nil signSt.vaultSalt = nil signSt.v2IV = nil signSt.vaultOpen = false signSt.identities = nil } func saveVault() { // Refuse to save if state is incomplete. Writing "{}" here would destroy // the on-disk vault when called from a refresh-then-modify flow where // signSt.vaultHash hadn't been rehydrated yet. if !signSt.vaultExists || len(signSt.vaultHash) == 0 { return } // Persist argon2M used at create time, so unlock uses the right param even // if defaults change later. Legacy vaults (no field) default to argon2MLegacy. mField := signSt.vaultArgon2M if mField == 0 { mField = argon2MLegacy } b := []byte{:512}[:0] b = b | "{\"version\":" b = b | helpers.Itoa(int64(signSt.vaultVersion)) b = b | ",\"argon2M\":" b = b | helpers.Itoa(int64(mField)) b = b | ",\"vaultHash\":" b = b | helpers.JsonString(signSt.vaultHash) b = b | ",\"salt\":" b = b | helpers.JsonString(helpers.Base64Encode(signSt.vaultSalt)) b = b | ",\"identities\":[" for i, id := range signSt.identities { if i > 0 { b = b | "," } encSk, ok := encryptField(id.Seckey) if !ok { encSk = id.Seckey } b = b | "{\"pubkey\":" b = b | helpers.JsonString(id.Pubkey) b = b | ",\"seckey\":" b = b | helpers.JsonString(encSk) b = b | ",\"name\":" b = b | helpers.JsonString(id.Name) b = b | "}" } b = b | "]}" signSt.vaultRawCache = string(b) ext.StorageSet(vaultStorageKey, signSt.vaultRawCache) if signSt.vaultOpen && ext.IsInPage() { cacheSession() } } func restorePlaintextVault(data string) { kIdentities := string(push([]byte(nil), 'i', 'd', 'e', 'n', 't', 'i', 't', 'i', 'e', 's')) ids := helpers.JsonGetValue(data, kIdentities) if ids == "" { return } kPubkey := string(push([]byte(nil), 'p', 'u', 'b', 'k', 'e', 'y')) kSeckey := string(push([]byte(nil), 's', 'e', 'c', 'k', 'e', 'y')) pk := helpers.JsonGetString(ids, kPubkey) sk := helpers.JsonGetString(ids, kSeckey) if pk == "" || sk == "" { return } signSt.vaultOpen = true signSt.vaultExists = true signSt.vaultVersion = 0 signSt.identities = []identity{{Pubkey: pk, Seckey: sk, Name: ""}} signSt.activeIdx = 0 } const sessionCacheKey = "vault-session" func cacheSession() { if !ext.IsInPage() || !signSt.vaultOpen || len(signSt.vaultKey) == 0 { return } b := []byte(nil) | helpers.HexEncode(signSt.vaultKey) b = b | ":" b = b | helpers.HexEncode(signSt.vaultSalt) b = b | ":" b = b | helpers.Itoa(int64(signSt.vaultVersion)) b = b | ":" aid := activeIdentity() if aid != nil { b = b | aid.Pubkey } kSession := string(push([]byte(nil), 'v', 'a', 'u', 'l', 't', '-', 's', 'e', 's', 's', 'i', 'o', 'n')) ext.SessionSet(kSession, string(b)) } func clearSessionCache() { kSession := string(push([]byte(nil), 'v', 'a', 'u', 'l', 't', '-', 's', 'e', 's', 's', 'i', 'o', 'n')) ext.SessionSet(kSession, "") } func tryRestoreSession() { kSession := string(push([]byte(nil), 'v', 'a', 'u', 'l', 't', '-', 's', 'e', 's', 's', 'i', 'o', 'n')) ext.SessionGet(kSession, func(val string) { if val == "" { return } var parts [6]string n := 0 start := 0 for i := 0; i < len(val) && n < 5; i++ { if val[i] == ':' { parts[n] = val[start:i] n++ start = i + 1 } } parts[n] = val[start:] n++ data := signSt.vaultRawCache if data == "" { clearSessionCache() return } // Detect format by part count and whether parts[2] is a 1-char // version digit (v3) or a long hex salt (v2). // v3: key:salt:version[:activePubkey] - parts[2] is 1 char // v2: key:iv:salt:version[:activePubkey] - parts[2] is hex salt isV3 := n >= 3 && len(parts[2]) == 1 if isV3 { key := helpers.HexDecode(parts[0]) salt := helpers.HexDecode(parts[1]) if len(key) != 32 { clearSessionCache() return } ver := parseSessionVer(parts[2]) if !finishUnlock(data, key, nil, salt, ver) { clearSessionCache() return } if n >= 4 { restoreActiveIdx(parts[3]) } } else if n >= 4 { key := helpers.HexDecode(parts[0]) iv := helpers.HexDecode(parts[1]) salt := helpers.HexDecode(parts[2]) if len(key) != 32 || len(iv) != 12 { clearSessionCache() return } ver := parseSessionVer(parts[3]) if !finishUnlock(data, key, iv, salt, ver) { clearSessionCache() return } if n >= 5 { restoreActiveIdx(parts[4]) } } else { clearSessionCache() } }) } func parseSessionVer(s string) (n int32) { if len(s) == 1 { if s[0] == '1' { return 1 } if s[0] == '2' { return 2 } } return 3 } // encryptFieldWith encrypts using explicit key (for export with different password). // Generates a fresh 12-byte random IV per call, prepended to ciphertext. // Output: base64(iv[12] || ciphertext || tag[16]). func encryptFieldWith(plain string, key []byte) (s string, ok bool) { if len(key) == 0 { return "", false } iv := []byte{:12} subtle.RandomBytes(iv) ct := aes256gcm.Seal(key, iv, []byte(plain), nil) if ct == nil { return "", false } out := []byte{:0:12 + len(ct)} out = out | iv out = out | ct return helpers.Base64Encode(out), true } // encryptField encrypts a plaintext string using signSt.vaultKey via AES-256-GCM. // Fresh random IV per call - no nonce reuse. func encryptField(plain string) (s string, ok bool) { if !signSt.vaultOpen || len(signSt.vaultKey) == 0 { return "", false } return encryptFieldWith(plain, signSt.vaultKey) } // decryptField decrypts a base64-encoded AES-256-GCM ciphertext. // v3 format: base64(iv[12] || ciphertext || tag[16]). // v2 compat: if signSt.v2IV is set, tries old format (no prepended IV) as fallback. func decryptField(enc string) (s string, ok2 bool) { if !signSt.vaultOpen || len(signSt.vaultKey) == 0 { return "", false } raw := helpers.Base64Decode(enc) if raw == nil { return "", false } // v3: first 12 bytes are per-field IV. if len(raw) >= 12+16 { iv := raw[:12] ct := raw[12:] pt, ok := aes256gcm.Open(signSt.vaultKey, iv, ct, nil) if ok { return string(pt), true } } // v2 fallback: shared IV from vault JSON. if len(signSt.v2IV) == 12 { pt, ok := aes256gcm.Open(signSt.vaultKey, signSt.v2IV, raw, nil) if ok { return string(pt), true } } return "", false } func passwordHash(pw string) (s string) { return subtle.SHA256Hex([]byte(pw)) } func unlockLegacy(data, password string) (ok bool) { sepIdx := -1 for i := 0; i < len(data); i++ { if data[i] == ':' { sepIdx = i break } } if sepIdx < 1 { return false } iv := helpers.HexDecode(data[:sepIdx]) ct := helpers.HexDecode(data[sepIdx+1:]) if iv == nil || ct == nil { return false } key := legacyDeriveKey(password) pt := subtle.AESCBCDecrypt(key[:], iv, ct) if len(pt) == 0 { return false } parseLegacyIdentities(string(pt)) newSalt := []byte{:32} subtle.RandomBytes(newSalt) hash := passwordHash(password) if hash == "" { return true } newKey := subtle.Argon2idDeriveKey(password, newSalt, argon2T, argon2MNew, argon2P, argon2DKLen) if len(newKey) == 0 { return true } signSt.vaultKey = newKey signSt.vaultSalt = newSalt signSt.vaultHash = hash signSt.vaultVersion = 3 signSt.vaultArgon2M = argon2MNew signSt.vaultOpen = true signSt.vaultExists = true saveVault() return true } // legacyDeriveKey derives a 32-byte key via 100000 rounds of SHA-256 over // "smesh-vault-salt:" | password. The prefix is the one the original signer // used and must not be renamed: it is the interop contract for importing a // legacy (v1) vault. func legacyDeriveKey(password string) (v [32]byte) { saltPrefix := push([]byte(nil), 's', 'm', 'e', 's', 'h', '-', 'v', 'a', 'u', 'l', 't', '-', 's', 'a', 'l', 't', ':') saltPrefix = saltPrefix | password h := sha256.Sum(saltPrefix) for i := 0; i < 100000; i++ { h = sha256.Sum(h[:]) } return h } // parseLegacyIdentities parses plaintext JSON array of signSt.identities from old format. func parseLegacyIdentities(s string) { signSt.identities = nil signSt.activeIdx = -1 i := 0 for i < len(s) && s[i] != '[' { i++ } i++ for i < len(s) { for i < len(s) && s[i] != '{' && s[i] != ']' { i++ } if i >= len(s) || s[i] == ']' { break } end := i + 1 depth := 1 for end < len(s) && depth > 0 { if s[end] == '{' { depth++ } else if s[end] == '}' { depth-- } else if s[end] == '"' { end++ for end < len(s) && s[end] != '"' { if s[end] == '\\' { end++ } end++ } } end++ } obj := s[i:end] kPubkey := string(push([]byte(nil), 'p', 'u', 'b', 'k', 'e', 'y')) kPublicKey := string(push([]byte(nil), 'p', 'u', 'b', 'l', 'i', 'c', 'K', 'e', 'y')) kPub := string(push([]byte(nil), 'p', 'u', 'b')) kSeckey := string(push([]byte(nil), 's', 'e', 'c', 'k', 'e', 'y')) kPrivkey := string(push([]byte(nil), 'p', 'r', 'i', 'v', 'k', 'e', 'y')) kSecretKey := string(push([]byte(nil), 's', 'e', 'c', 'r', 'e', 't', 'K', 'e', 'y')) kSec := string(push([]byte(nil), 's', 'e', 'c')) kKey := string(push([]byte(nil), 'k', 'e', 'y')) kName := string(push([]byte(nil), 'n', 'a', 'm', 'e')) kNick := string(push([]byte(nil), 'n', 'i', 'c', 'k')) kLabel := string(push([]byte(nil), 'l', 'a', 'b', 'e', 'l')) pk := helpers.JsonGetString(obj, kPubkey) if pk == "" { pk = helpers.JsonGetString(obj, kPublicKey) } if pk == "" { pk = helpers.JsonGetString(obj, kPub) } sk := helpers.JsonGetString(obj, kSeckey) if sk == "" { sk = helpers.JsonGetString(obj, kPrivkey) } if sk == "" { sk = helpers.JsonGetString(obj, kSecretKey) } if sk == "" { sk = helpers.JsonGetString(obj, kSec) } if sk == "" { sk = helpers.JsonGetString(obj, kKey) } nm := helpers.JsonGetString(obj, kName) if nm == "" { nm = helpers.JsonGetString(obj, kNick) } if nm == "" { nm = helpers.JsonGetString(obj, kLabel) } // nsec decode kNsec1 := string(push([]byte(nil), 'n', 's', 'e', 'c', '1')) if len(sk) >= 5 && sk[:5] == kNsec1 { skBytes := helpers.DecodeNsec(sk) if skBytes != nil { sk = helpers.HexEncode(skBytes) } } // derive pubkey if absent if pk == "" && sk != "" { skBytes := helpers.HexDecode(sk) if skBytes != nil { dpk, ok := schnorr.PubKeyFromSecKey(skBytes) if ok { pk = helpers.HexEncode(dpk) } } } if pk != "" && sk != "" { signSt.identities = push(signSt.identities, identity{Pubkey: pk, Seckey: sk, Name: nm}) } i = end } if len(signSt.identities) > 0 { signSt.activeIdx = 0 } }