nwc.mx raw
1 package main
2
3 import (
4 "git.smesh.lol/smesh/web/common/crypto/nip44"
5 "git.smesh.lol/smesh/web/common/helpers"
6 "git.smesh.lol/smesh/web/common/jsbridge/ext"
7 "git.smesh.lol/smesh/web/common/jsbridge/schnorr"
8 "git.smesh.lol/smesh/web/common/jsbridge/subtle"
9 "git.smesh.lol/smesh/web/common/nostr"
10 nosig "git.smesh.lol/smesh/web/common/nostr/sig"
11 )
12
13 // NWC: Nostr Wallet Connect (NIP-47) connection management.
14 // Connections stored in vault storage alongside identities.
15 // Each connection has its own keypair (walletPubkey + ephemeral secret).
16
17 type nwcConn struct {
18 Alias string
19 WalletPK string // wallet service pubkey
20 RelayURL string
21 Secret string // our NIP-47 ephemeral secret, hex
22 }
23
24 var walletConns []nwcConn
25
26 const nwcStorageKey = "smesh-nwc"
27
28 func loadNWC() {
29 kNwc := string(append([]byte(nil), 's', 'm', 'e', 's', 'h', '-', 'n', 'w', 'c'))
30 ext.StorageGet(kNwc, func(data string) {
31 if data != "" {
32 parseNWCConns(data)
33 }
34 })
35 }
36
37 func parseNWCConns(s string) {
38 walletConns = nil
39 i := 0
40 for i < len(s) && s[i] != '[' {
41 i++
42 }
43 i++
44 for i < len(s) {
45 for i < len(s) && s[i] != '{' && s[i] != ']' {
46 i++
47 }
48 if i >= len(s) || s[i] == ']' {
49 break
50 }
51 end := i + 1
52 depth := 1
53 for end < len(s) && depth > 0 {
54 if s[end] == '{' {
55 depth++
56 } else if s[end] == '}' {
57 depth--
58 } else if s[end] == '"' {
59 end++
60 for end < len(s) && s[end] != '"' {
61 if s[end] == '\\' {
62 end++
63 }
64 end++
65 }
66 }
67 end++
68 }
69 obj := s[i:end]
70 kAlias := string(append([]byte(nil), 'a', 'l', 'i', 'a', 's'))
71 kWalletPK := string(append([]byte(nil), 'w', 'a', 'l', 'l', 'e', 't', 'P', 'K'))
72 kRelay := string(append([]byte(nil), 'r', 'e', 'l', 'a', 'y'))
73 kSecret := string(append([]byte(nil), 's', 'e', 'c', 'r', 'e', 't'))
74 walletConns = append(walletConns, nwcConn{
75 Alias: helpers.JsonGetString(obj, kAlias),
76 WalletPK: helpers.JsonGetString(obj, kWalletPK),
77 RelayURL: helpers.JsonGetString(obj, kRelay),
78 Secret: helpers.JsonGetString(obj, kSecret),
79 })
80 i = end
81 }
82 }
83
84 func saveNWC() {
85 b := append([]byte(nil), '[')
86 for i, c := range walletConns {
87 if i > 0 {
88 b = append(b, ',')
89 }
90 b = append(b, '{', '"', 'a', 'l', 'i', 'a', 's', '"', ':')
91 b = b | helpers.JsonString(c.Alias)
92 b = append(b, ',', '"', 'w', 'a', 'l', 'l', 'e', 't', 'P', 'K', '"', ':')
93 b = b | helpers.JsonString(c.WalletPK)
94 b = append(b, ',', '"', 'r', 'e', 'l', 'a', 'y', '"', ':')
95 b = b | helpers.JsonString(c.RelayURL)
96 b = append(b, ',', '"', 's', 'e', 'c', 'r', 'e', 't', '"', ':')
97 b = b | helpers.JsonString(c.Secret)
98 b = append(b, '}')
99 }
100 b = append(b, ']')
101 kNwc := string(append([]byte(nil), 's', 'm', 'e', 's', 'h', '-', 'n', 'w', 'c'))
102 ext.StorageSet(kNwc, string(b))
103 }
104
105 func nwcList() (s string) {
106 b := append([]byte(nil), '{', '"', 'r', 'e', 's', 'u', 'l', 't', '"', ':', '[')
107 for i, c := range walletConns {
108 if i > 0 {
109 b = append(b, ',')
110 }
111 b = append(b, '{', '"', 'a', 'l', 'i', 'a', 's', '"', ':')
112 b = b | helpers.JsonString(c.Alias)
113 b = append(b, ',', '"', 'w', 'a', 'l', 'l', 'e', 't', 'P', 'K', '"', ':')
114 b = b | helpers.JsonString(c.WalletPK)
115 b = append(b, ',', '"', 'r', 'e', 'l', 'a', 'y', '"', ':')
116 b = b | helpers.JsonString(c.RelayURL)
117 b = append(b, '}')
118 }
119 return string(append(b, ']', '}'))
120 }
121
122 func nwcAdd(paramsJSON string) (s string) {
123 kURI := string(append([]byte(nil), 'u', 'r', 'i'))
124 kAlias := string(append([]byte(nil), 'a', 'l', 'i', 'a', 's'))
125 uri := helpers.JsonGetString(paramsJSON, kURI)
126 alias := helpers.JsonGetString(paramsJSON, kAlias)
127 if uri == "" {
128 return jsonErr(string(append([]byte(nil), 'm', 'i', 's', 's', 'i', 'n', 'g', ' ', 'u', 'r', 'i')))
129 }
130 walletPK, relay, secret := parseNWCURI(uri)
131 if walletPK == "" || relay == "" || secret == "" {
132 return jsonErr(string(append([]byte(nil), 'i', 'n', 'v', 'a', 'l', 'i', 'd', ' ', 'u', 'r', 'i')))
133 }
134 walletConns = append(walletConns, nwcConn{Alias: alias, WalletPK: walletPK, RelayURL: relay, Secret: secret})
135 saveNWC()
136 return jsonTrue()
137 }
138
139 func nwcRemove(paramsJSON string) (s string) {
140 kWalletPK := string(append([]byte(nil), 'w', 'a', 'l', 'l', 'e', 't', 'P', 'K'))
141 walletPK := helpers.JsonGetString(paramsJSON, kWalletPK)
142 for i, c := range walletConns {
143 if c.WalletPK == walletPK {
144 walletConns = walletConns[:i] | walletConns[i+1:]
145 saveNWC()
146 return jsonTrue()
147 }
148 }
149 return jsonFalse()
150 }
151
152 func nwcBuildRequest(paramsJSON string) (s string) {
153 kWalletPKB := string(append([]byte(nil), 'w', 'a', 'l', 'l', 'e', 't', 'P', 'K'))
154 kMethod := string(append([]byte(nil), 'm', 'e', 't', 'h', 'o', 'd'))
155 kParams := string(append([]byte(nil), 'p', 'a', 'r', 'a', 'm', 's'))
156 walletPK := helpers.JsonGetString(paramsJSON, kWalletPKB)
157 method := helpers.JsonGetString(paramsJSON, kMethod)
158 reqParams := helpers.JsonGetValue(paramsJSON, kParams)
159 if walletPK == "" || method == "" {
160 return jsonErr(string(append([]byte(nil), 'm', 'i', 's', 's', 'i', 'n', 'g', ' ', 'p', 'a', 'r', 'a', 'm', 's')))
161 }
162 var conn *nwcConn
163 for i := range walletConns {
164 if walletConns[i].WalletPK == walletPK {
165 conn = &walletConns[i]
166 break
167 }
168 }
169 if conn == nil {
170 return jsonErr(string(append([]byte(nil), 'c', 'o', 'n', 'n', 'e', 'c', 't', 'i', 'o', 'n', ' ', 'n', 'o', 't', ' ', 'f', 'o', 'u', 'n', 'd')))
171 }
172 sk32, ok := helpers.HexDecode32(conn.Secret)
173 if !ok {
174 return jsonErr(string(append([]byte(nil), 'i', 'n', 'v', 'a', 'l', 'i', 'd', ' ', 's', 'e', 'c', 'r', 'e', 't')))
175 }
176 pk32, ok := helpers.HexDecode32(walletPK)
177 if !ok {
178 return jsonErr(string(append([]byte(nil), 'i', 'n', 'v', 'a', 'l', 'i', 'd', ' ', 'w', 'a', 'l', 'l', 'e', 't', 'P', 'K')))
179 }
180 convKey, ok := nip44.ConversationKey(sk32, pk32)
181 if !ok {
182 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')))
183 }
184 reqID := randomHex(32)
185 reqIDJSON := helpers.JsonString(reqID)
186 methodJSON := helpers.JsonString(method)
187 bodyB := append([]byte(nil), '{', '"', 'i', 'd', '"', ':')
188 bodyB = bodyB | reqIDJSON
189 bodyB = append(bodyB, ',', '"', 'm', 'e', 't', 'h', 'o', 'd', '"', ':')
190 bodyB = bodyB | methodJSON
191 bodyB = append(bodyB, ',', '"', 'p', 'a', 'r', 'a', 'm', 's', '"', ':')
192 bodyB = bodyB | reqParams
193 bodyB = append(bodyB, '}')
194 body := string(bodyB)
195 var nonce [32]byte
196 subtle.RandomBytes(nonce[:])
197 encrypted := nip44.Encrypt(body, convKey, nonce)
198 ourPKBytes, ok := schnorr.PubKeyFromSecKey(sk32[:])
199 if !ok {
200 return jsonErr(string(append([]byte(nil), 'p', 'u', 'b', 'k', 'e', 'y', ' ', 'f', 'a', 'i', 'l', 'e', 'd')))
201 }
202 ev := &nostr.Event{
203 Kind: 23194,
204 Content: encrypted,
205 CreatedAt: 0,
206 Tags: nostr.Tags{nostr.Tag{"p", walletPK}},
207 PubKey: helpers.HexEncode(ourPKBytes),
208 }
209 (*nosig.Event)(ev).ComputeID()
210 idBytes := helpers.HexDecode(ev.ID)
211 var aux [32]byte
212 subtle.RandomBytes(aux[:])
213 sig, ok := schnorr.SignSchnorr(sk32[:], idBytes, aux[:])
214 if !ok {
215 return jsonErr(string(append([]byte(nil), 's', 'i', 'g', 'n', ' ', 'f', 'a', 'i', 'l', 'e', 'd')))
216 }
217 ev.Sig = helpers.HexEncode(sig)
218 rb := append([]byte(nil), '{', '"', 'r', 'e', 's', 'u', 'l', 't', '"', ':')
219 rb = rb | ev.ToJSON()
220 rb = append(rb, ',', '"', 'r', 'e', 'q', 'I', 'D', '"', ':')
221 rb = rb | reqIDJSON
222 rb = append(rb, '}')
223 return string(rb)
224 }
225
226 func nwcParseResponse(paramsJSON string) (s string) {
227 kWalletPKR := string(append([]byte(nil), 'w', 'a', 'l', 'l', 'e', 't', 'P', 'K'))
228 kContent := string(append([]byte(nil), 'c', 'o', 'n', 't', 'e', 'n', 't'))
229 walletPK := helpers.JsonGetString(paramsJSON, kWalletPKR)
230 content := helpers.JsonGetString(paramsJSON, kContent)
231 if walletPK == "" || content == "" {
232 return jsonErr(string(append([]byte(nil), 'm', 'i', 's', 's', 'i', 'n', 'g', ' ', 'p', 'a', 'r', 'a', 'm', 's')))
233 }
234 var conn *nwcConn
235 for i := range walletConns {
236 if walletConns[i].WalletPK == walletPK {
237 conn = &walletConns[i]
238 break
239 }
240 }
241 if conn == nil {
242 return jsonErr(string(append([]byte(nil), 'c', 'o', 'n', 'n', 'e', 'c', 't', 'i', 'o', 'n', ' ', 'n', 'o', 't', ' ', 'f', 'o', 'u', 'n', 'd')))
243 }
244 sk32, ok := helpers.HexDecode32(conn.Secret)
245 if !ok {
246 return jsonErr(string(append([]byte(nil), 'i', 'n', 'v', 'a', 'l', 'i', 'd', ' ', 's', 'e', 'c', 'r', 'e', 't')))
247 }
248 pk32, ok := helpers.HexDecode32(walletPK)
249 if !ok {
250 return jsonErr(string(append([]byte(nil), 'i', 'n', 'v', 'a', 'l', 'i', 'd', ' ', 'w', 'a', 'l', 'l', 'e', 't', 'P', 'K')))
251 }
252 convKey, ok := nip44.ConversationKey(sk32, pk32)
253 if !ok {
254 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')))
255 }
256 pt, ok := nip44.Decrypt(content, convKey)
257 if !ok {
258 return jsonErr(string(append([]byte(nil), 'd', 'e', 'c', 'r', 'y', 'p', 't', ' ', 'f', 'a', 'i', 'l', 'e', 'd')))
259 }
260 return jsonResult(pt)
261 }
262
263 // parseNWCURI parses nostr+walletconnect://<walletPK>?relay=<url>&secret=<hex>
264 func parseNWCURI(uri string) (walletPK, relay, secret string) {
265 scheme := string(append([]byte(nil), 'n', 'o', 's', 't', 'r', '+', 'w', 'a', 'l', 'l', 'e', 't', 'c', 'o', 'n', 'n', 'e', 'c', 't', ':', '/', '/'))
266 if len(uri) < len(scheme) || uri[:len(scheme)] != scheme {
267 return "", "", ""
268 }
269 rest := uri[len(scheme):]
270 // Find the '?' separating walletPK from query params
271 qIdx := -1
272 for i := 0; i < len(rest); i++ {
273 if rest[i] == '?' {
274 qIdx = i
275 break
276 }
277 }
278 if qIdx < 0 {
279 return "", "", ""
280 }
281 walletPK = rest[:qIdx]
282 query := rest[qIdx+1:]
283 // Parse key=value pairs separated by '&'
284 kRelay := string(append([]byte(nil), 'r', 'e', 'l', 'a', 'y'))
285 kSecret := string(append([]byte(nil), 's', 'e', 'c', 'r', 'e', 't'))
286 start := 0
287 for start <= len(query) {
288 end := start
289 for end < len(query) && query[end] != '&' {
290 end++
291 }
292 pair := query[start:end]
293 eqIdx := -1
294 for j := 0; j < len(pair); j++ {
295 if pair[j] == '=' {
296 eqIdx = j
297 break
298 }
299 }
300 if eqIdx > 0 {
301 k := pair[:eqIdx]
302 v := pair[eqIdx+1:]
303 if k == kRelay {
304 relay = v
305 } else if k == kSecret {
306 secret = v
307 }
308 }
309 start = end + 1
310 }
311 if walletPK == "" || relay == "" || secret == "" {
312 return "", "", ""
313 }
314 return walletPK, relay, secret
315 }
316
317 func randomHex(n int32) (s string) {
318 b := []byte{:n}
319 subtle.RandomBytes(b)
320 return helpers.HexEncode(b)
321 }
322
323