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