uri.go raw
1 package tunnel
2
3 import (
4 "errors"
5 "fmt"
6 "net/url"
7 "strconv"
8 "strings"
9
10 "git.smesh.lol/nostrino/pkg/crypto/secp256k1"
11 "git.smesh.lol/nostrino/pkg/nostr"
12 )
13
14 // Scheme is the connection URI scheme of a chain-data gateway.
15 const Scheme = "nostr+chaindata"
16
17 // URI names a gateway and the relays where it can be reached.
18 //
19 // nostr+chaindata://<gateway-pubkey>?relay=<wss-relay>&relay=<wss-relay>&chains=btc,xbt&v=1&name=<label>
20 //
21 // The gateway public key replaces the shared secret of NIP-46 style
22 // connection strings: the gateway is a public chain-data service with its own
23 // transport identity, not a signer, and a client needs no secret to talk to
24 // it. Client authentication, where an operator wants it, is a pubkey
25 // allowlist on the gateway.
26 type URI struct {
27 Gateway [32]byte
28 Relays []string
29 // Bridges are optional WebSocket data-plane endpoints. When present the
30 // client tries them first and falls back to the relay stream.
31 Bridges []string
32 Chains []string
33 Version int
34 Name string
35 }
36
37 // ParseURI parses a nostr+chaindata connection string.
38 func ParseURI(raw string) (*URI, error) {
39 u, err := url.Parse(strings.TrimSpace(raw))
40 if err != nil {
41 return nil, fmt.Errorf("tunnel: parse uri: %w", err)
42 }
43 if !strings.EqualFold(u.Scheme, Scheme) {
44 return nil, fmt.Errorf("tunnel: scheme is %q, want %q", u.Scheme, Scheme)
45 }
46 if u.User != nil {
47 return nil, errors.New("tunnel: connection uri has no userinfo")
48 }
49 host := strings.ToLower(u.Host)
50 if host == "" {
51 return nil, errors.New("tunnel: connection uri has no gateway pubkey")
52 }
53 if len(host) != 64 {
54 return nil, errors.New("tunnel: gateway pubkey must be 64 hex characters")
55 }
56 out := &URI{Gateway: [32]byte{}, Version: 1}
57 if err := nostr.Decode32(host, out.Gateway[:]); err != nil {
58 return nil, fmt.Errorf("tunnel: gateway pubkey: %w", err)
59 }
60 if !secp256k1.ValidatePubKey(out.Gateway) {
61 return nil, errors.New("tunnel: gateway pubkey is not on the curve")
62 }
63 q := u.Query()
64 for _, relay := range q["relay"] {
65 relay = strings.TrimSpace(relay)
66 if relay == "" {
67 continue
68 }
69 if !strings.HasPrefix(relay, "ws://") && !strings.HasPrefix(relay, "wss://") {
70 return nil, fmt.Errorf("tunnel: relay %q is not ws:// or wss://", relay)
71 }
72 out.Relays = append(out.Relays, relay)
73 }
74 if len(out.Relays) == 0 {
75 return nil, errors.New("tunnel: connection uri has no relay")
76 }
77 for _, bridge := range q["bridge"] {
78 bridge = strings.TrimSpace(bridge)
79 if bridge == "" {
80 continue
81 }
82 if !strings.HasPrefix(bridge, "ws://") && !strings.HasPrefix(bridge, "wss://") {
83 return nil, fmt.Errorf("tunnel: bridge %q is not ws:// or wss://", bridge)
84 }
85 out.Bridges = append(out.Bridges, bridge)
86 }
87 if raw := q.Get("v"); raw != "" {
88 v, err := strconv.Atoi(raw)
89 if err != nil || v != 1 {
90 return nil, fmt.Errorf("tunnel: unsupported protocol version %q", raw)
91 }
92 out.Version = v
93 }
94 if raw := q.Get("chains"); raw != "" {
95 for _, c := range strings.Split(raw, ",") {
96 c = strings.ToLower(strings.TrimSpace(c))
97 if c != "" {
98 out.Chains = append(out.Chains, c)
99 }
100 }
101 }
102 out.Name = q.Get("name")
103 return out, nil
104 }
105
106 // String renders the canonical connection URI.
107 func (u *URI) String() string {
108 q := url.Values{}
109 for _, relay := range u.Relays {
110 q.Add("relay", relay)
111 }
112 if len(u.Chains) > 0 {
113 q.Set("chains", strings.Join(u.Chains, ","))
114 }
115 for _, bridge := range u.Bridges {
116 q.Add("bridge", bridge)
117 }
118 q.Set("v", strconv.Itoa(u.Version))
119 if u.Name != "" {
120 q.Set("name", u.Name)
121 }
122 return Scheme + "://" + nostr.Hex(u.Gateway[:]) + "?" + q.Encode()
123 }
124
125 // ChainAllowed reports whether the gateway advertised the chain. An empty
126 // advertised list means the URI did not constrain it.
127 func (u *URI) ChainAllowed(chain string) bool {
128 if len(u.Chains) == 0 {
129 return true
130 }
131 for _, c := range u.Chains {
132 if strings.EqualFold(c, chain) {
133 return true
134 }
135 }
136 return false
137 }
138