package main import ( "git.smesh.lol/smesh/web/common/crypto/nip44" "git.smesh.lol/smesh/web/common/helpers" "git.smesh.lol/smesh/web/common/jsbridge/ext" "git.smesh.lol/smesh/web/common/jsbridge/schnorr" "git.smesh.lol/smesh/web/common/jsbridge/subtle" "git.smesh.lol/smesh/web/common/nostr" nosig "git.smesh.lol/smesh/web/common/nostr/sig" ) // NWC: Nostr Wallet Connect (NIP-47) connection management. // Connections stored in vault storage alongside identities. // Each connection has its own keypair (walletPubkey + ephemeral secret). type nwcConn struct { Alias string WalletPK string // wallet service pubkey RelayURL string Secret string // our NIP-47 ephemeral secret, hex } var walletConns []nwcConn const nwcStorageKey = "smesh-nwc" func loadNWC() { kNwc := string(append([]byte(nil), 's', 'm', 'e', 's', 'h', '-', 'n', 'w', 'c')) ext.StorageGet(kNwc, func(data string) { if data != "" { parseNWCConns(data) } }) } func parseNWCConns(s string) { walletConns = nil 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] kAlias := string(append([]byte(nil), 'a', 'l', 'i', 'a', 's')) kWalletPK := string(append([]byte(nil), 'w', 'a', 'l', 'l', 'e', 't', 'P', 'K')) kRelay := string(append([]byte(nil), 'r', 'e', 'l', 'a', 'y')) kSecret := string(append([]byte(nil), 's', 'e', 'c', 'r', 'e', 't')) walletConns = append(walletConns, nwcConn{ Alias: helpers.JsonGetString(obj, kAlias), WalletPK: helpers.JsonGetString(obj, kWalletPK), RelayURL: helpers.JsonGetString(obj, kRelay), Secret: helpers.JsonGetString(obj, kSecret), }) i = end } } func saveNWC() { b := append([]byte(nil), '[') for i, c := range walletConns { if i > 0 { b = append(b, ',') } b = append(b, '{', '"', 'a', 'l', 'i', 'a', 's', '"', ':') b = b | helpers.JsonString(c.Alias) b = append(b, ',', '"', 'w', 'a', 'l', 'l', 'e', 't', 'P', 'K', '"', ':') b = b | helpers.JsonString(c.WalletPK) b = append(b, ',', '"', 'r', 'e', 'l', 'a', 'y', '"', ':') b = b | helpers.JsonString(c.RelayURL) b = append(b, ',', '"', 's', 'e', 'c', 'r', 'e', 't', '"', ':') b = b | helpers.JsonString(c.Secret) b = append(b, '}') } b = append(b, ']') kNwc := string(append([]byte(nil), 's', 'm', 'e', 's', 'h', '-', 'n', 'w', 'c')) ext.StorageSet(kNwc, string(b)) } func nwcList() (s string) { b := append([]byte(nil), '{', '"', 'r', 'e', 's', 'u', 'l', 't', '"', ':', '[') for i, c := range walletConns { if i > 0 { b = append(b, ',') } b = append(b, '{', '"', 'a', 'l', 'i', 'a', 's', '"', ':') b = b | helpers.JsonString(c.Alias) b = append(b, ',', '"', 'w', 'a', 'l', 'l', 'e', 't', 'P', 'K', '"', ':') b = b | helpers.JsonString(c.WalletPK) b = append(b, ',', '"', 'r', 'e', 'l', 'a', 'y', '"', ':') b = b | helpers.JsonString(c.RelayURL) b = append(b, '}') } return string(append(b, ']', '}')) } func nwcAdd(paramsJSON string) (s string) { kURI := string(append([]byte(nil), 'u', 'r', 'i')) kAlias := string(append([]byte(nil), 'a', 'l', 'i', 'a', 's')) uri := helpers.JsonGetString(paramsJSON, kURI) alias := helpers.JsonGetString(paramsJSON, kAlias) if uri == "" { return jsonErr(string(append([]byte(nil), 'm', 'i', 's', 's', 'i', 'n', 'g', ' ', 'u', 'r', 'i'))) } walletPK, relay, secret := parseNWCURI(uri) if walletPK == "" || relay == "" || secret == "" { return jsonErr(string(append([]byte(nil), 'i', 'n', 'v', 'a', 'l', 'i', 'd', ' ', 'u', 'r', 'i'))) } walletConns = append(walletConns, nwcConn{Alias: alias, WalletPK: walletPK, RelayURL: relay, Secret: secret}) saveNWC() return jsonTrue() } func nwcRemove(paramsJSON string) (s string) { kWalletPK := string(append([]byte(nil), 'w', 'a', 'l', 'l', 'e', 't', 'P', 'K')) walletPK := helpers.JsonGetString(paramsJSON, kWalletPK) for i, c := range walletConns { if c.WalletPK == walletPK { walletConns = walletConns[:i] | walletConns[i+1:] saveNWC() return jsonTrue() } } return jsonFalse() } func nwcBuildRequest(paramsJSON string) (s string) { kWalletPKB := string(append([]byte(nil), 'w', 'a', 'l', 'l', 'e', 't', 'P', 'K')) kMethod := string(append([]byte(nil), 'm', 'e', 't', 'h', 'o', 'd')) kParams := string(append([]byte(nil), 'p', 'a', 'r', 'a', 'm', 's')) walletPK := helpers.JsonGetString(paramsJSON, kWalletPKB) method := helpers.JsonGetString(paramsJSON, kMethod) reqParams := helpers.JsonGetValue(paramsJSON, kParams) if walletPK == "" || method == "" { return jsonErr(string(append([]byte(nil), 'm', 'i', 's', 's', 'i', 'n', 'g', ' ', 'p', 'a', 'r', 'a', 'm', 's'))) } var conn *nwcConn for i := range walletConns { if walletConns[i].WalletPK == walletPK { conn = &walletConns[i] break } } if conn == nil { return jsonErr(string(append([]byte(nil), 'c', 'o', 'n', 'n', 'e', 'c', 't', 'i', 'o', 'n', ' ', 'n', 'o', 't', ' ', 'f', 'o', 'u', 'n', 'd'))) } sk32, ok := helpers.HexDecode32(conn.Secret) if !ok { return jsonErr(string(append([]byte(nil), 'i', 'n', 'v', 'a', 'l', 'i', 'd', ' ', 's', 'e', 'c', 'r', 'e', 't'))) } pk32, ok := helpers.HexDecode32(walletPK) if !ok { return jsonErr(string(append([]byte(nil), 'i', 'n', 'v', 'a', 'l', 'i', 'd', ' ', 'w', 'a', 'l', 'l', 'e', 't', 'P', 'K'))) } convKey, ok := nip44.ConversationKey(sk32, pk32) if !ok { return jsonErr(string(append([]byte(nil), 'k', 'e', 'y', ' ', 'd', 'e', 'r', 'i', 'v', 'a', 't', 'i', 'o', 'n', ' ', 'f', 'a', 'i', 'l', 'e', 'd'))) } reqID := randomHex(32) reqIDJSON := helpers.JsonString(reqID) methodJSON := helpers.JsonString(method) bodyB := append([]byte(nil), '{', '"', 'i', 'd', '"', ':') bodyB = bodyB | reqIDJSON bodyB = append(bodyB, ',', '"', 'm', 'e', 't', 'h', 'o', 'd', '"', ':') bodyB = bodyB | methodJSON bodyB = append(bodyB, ',', '"', 'p', 'a', 'r', 'a', 'm', 's', '"', ':') bodyB = bodyB | reqParams bodyB = append(bodyB, '}') body := string(bodyB) var nonce [32]byte subtle.RandomBytes(nonce[:]) encrypted := nip44.Encrypt(body, convKey, nonce) ourPKBytes, ok := schnorr.PubKeyFromSecKey(sk32[:]) if !ok { return jsonErr(string(append([]byte(nil), 'p', 'u', 'b', 'k', 'e', 'y', ' ', 'f', 'a', 'i', 'l', 'e', 'd'))) } ev := &nostr.Event{ Kind: 23194, Content: encrypted, CreatedAt: 0, Tags: nostr.Tags{nostr.Tag{"p", walletPK}}, PubKey: helpers.HexEncode(ourPKBytes), } (*nosig.Event)(ev).ComputeID() idBytes := helpers.HexDecode(ev.ID) var aux [32]byte subtle.RandomBytes(aux[:]) sig, ok := schnorr.SignSchnorr(sk32[:], idBytes, aux[:]) if !ok { return jsonErr(string(append([]byte(nil), 's', 'i', 'g', 'n', ' ', 'f', 'a', 'i', 'l', 'e', 'd'))) } ev.Sig = helpers.HexEncode(sig) rb := append([]byte(nil), '{', '"', 'r', 'e', 's', 'u', 'l', 't', '"', ':') rb = rb | ev.ToJSON() rb = append(rb, ',', '"', 'r', 'e', 'q', 'I', 'D', '"', ':') rb = rb | reqIDJSON rb = append(rb, '}') return string(rb) } func nwcParseResponse(paramsJSON string) (s string) { kWalletPKR := string(append([]byte(nil), 'w', 'a', 'l', 'l', 'e', 't', 'P', 'K')) kContent := string(append([]byte(nil), 'c', 'o', 'n', 't', 'e', 'n', 't')) walletPK := helpers.JsonGetString(paramsJSON, kWalletPKR) content := helpers.JsonGetString(paramsJSON, kContent) if walletPK == "" || content == "" { return jsonErr(string(append([]byte(nil), 'm', 'i', 's', 's', 'i', 'n', 'g', ' ', 'p', 'a', 'r', 'a', 'm', 's'))) } var conn *nwcConn for i := range walletConns { if walletConns[i].WalletPK == walletPK { conn = &walletConns[i] break } } if conn == nil { return jsonErr(string(append([]byte(nil), 'c', 'o', 'n', 'n', 'e', 'c', 't', 'i', 'o', 'n', ' ', 'n', 'o', 't', ' ', 'f', 'o', 'u', 'n', 'd'))) } sk32, ok := helpers.HexDecode32(conn.Secret) if !ok { return jsonErr(string(append([]byte(nil), 'i', 'n', 'v', 'a', 'l', 'i', 'd', ' ', 's', 'e', 'c', 'r', 'e', 't'))) } pk32, ok := helpers.HexDecode32(walletPK) if !ok { return jsonErr(string(append([]byte(nil), 'i', 'n', 'v', 'a', 'l', 'i', 'd', ' ', 'w', 'a', 'l', 'l', 'e', 't', 'P', 'K'))) } convKey, ok := nip44.ConversationKey(sk32, pk32) if !ok { return jsonErr(string(append([]byte(nil), 'k', 'e', 'y', ' ', 'd', 'e', 'r', 'i', 'v', 'a', 't', 'i', 'o', 'n', ' ', 'f', 'a', 'i', 'l', 'e', 'd'))) } pt, ok := nip44.Decrypt(content, convKey) if !ok { return jsonErr(string(append([]byte(nil), 'd', 'e', 'c', 'r', 'y', 'p', 't', ' ', 'f', 'a', 'i', 'l', 'e', 'd'))) } return jsonResult(pt) } // parseNWCURI parses nostr+walletconnect://?relay=&secret= func parseNWCURI(uri string) (walletPK, relay, secret string) { scheme := string(append([]byte(nil), 'n', 'o', 's', 't', 'r', '+', 'w', 'a', 'l', 'l', 'e', 't', 'c', 'o', 'n', 'n', 'e', 'c', 't', ':', '/', '/')) if len(uri) < len(scheme) || uri[:len(scheme)] != scheme { return "", "", "" } rest := uri[len(scheme):] // Find the '?' separating walletPK from query params qIdx := -1 for i := 0; i < len(rest); i++ { if rest[i] == '?' { qIdx = i break } } if qIdx < 0 { return "", "", "" } walletPK = rest[:qIdx] query := rest[qIdx+1:] // Parse key=value pairs separated by '&' kRelay := string(append([]byte(nil), 'r', 'e', 'l', 'a', 'y')) kSecret := string(append([]byte(nil), 's', 'e', 'c', 'r', 'e', 't')) start := 0 for start <= len(query) { end := start for end < len(query) && query[end] != '&' { end++ } pair := query[start:end] eqIdx := -1 for j := 0; j < len(pair); j++ { if pair[j] == '=' { eqIdx = j break } } if eqIdx > 0 { k := pair[:eqIdx] v := pair[eqIdx+1:] if k == kRelay { relay = v } else if k == kSecret { secret = v } } start = end + 1 } if walletPK == "" || relay == "" || secret == "" { return "", "", "" } return walletPK, relay, secret } func randomHex(n int32) (s string) { b := []byte{:n} subtle.RandomBytes(b) return helpers.HexEncode(b) }