nip42.go raw

   1  package auth
   2  
   3  import (
   4  	"crypto/rand"
   5  	"encoding/base64"
   6  	"net/url"
   7  	"strings"
   8  	"time"
   9  
  10  	"next.orly.dev/pkg/nostr/encoders/event"
  11  	"next.orly.dev/pkg/nostr/encoders/kind"
  12  	"next.orly.dev/pkg/nostr/encoders/tag"
  13  	"next.orly.dev/pkg/nostr/utils"
  14  	"next.orly.dev/pkg/nostr/utils/normalize"
  15  	"next.orly.dev/pkg/lol/chk"
  16  	"next.orly.dev/pkg/lol/errorf"
  17  )
  18  
  19  // GenerateChallenge creates a reasonable, 16-byte base64 challenge string
  20  func GenerateChallenge() (b []byte) {
  21  	bb := make([]byte, 12)
  22  	b = make([]byte, 16)
  23  	_, _ = rand.Read(bb)
  24  	base64.URLEncoding.Encode(b, bb)
  25  	return
  26  }
  27  
  28  // CreateUnsigned creates an event which should be sent via an "AUTH" command.
  29  // If the authentication succeeds, the user will be authenticated as a pubkey.
  30  func CreateUnsigned(pubkey, challenge []byte, relayURL string) (ev *event.E) {
  31  	return &event.E{
  32  		Pubkey:    pubkey,
  33  		CreatedAt: time.Now().Unix(),
  34  		Kind:      kind.ClientAuthentication.K,
  35  		Tags: tag.NewS(
  36  			tag.NewFromAny("relay", relayURL),
  37  			tag.NewFromAny("challenge", string(challenge)),
  38  		),
  39  	}
  40  }
  41  
  42  // helper function for ValidateAuthEvent.
  43  // Normalizes the URL to ensure it has a proper ws:// or wss:// scheme before parsing.
  44  func parseURL(input string) (*url.URL, error) {
  45  	// Use normalize.URL to ensure the URL has a proper scheme
  46  	// This handles cases like "relay.example.com:3334" which url.Parse
  47  	// would incorrectly interpret as scheme="relay.example.com" opaque="3334"
  48  	normalized := string(normalize.URL(input))
  49  	return url.Parse(
  50  		strings.ToLower(
  51  			strings.TrimSuffix(normalized, "/"),
  52  		),
  53  	)
  54  }
  55  
  56  var (
  57  	// ChallengeTag is the tag for the challenge in a NIP-42 auth event
  58  	// (prevents relay attacks).
  59  	ChallengeTag = []byte("challenge")
  60  	// RelayTag is the relay tag for a NIP-42 auth event (prevents cross-server
  61  	// attacks).
  62  	RelayTag = []byte("relay")
  63  )
  64  
  65  // Validate checks whether an event is a valid NIP-42 event for a given
  66  // challenge and relayURL. The result of the validation is encoded in the ok
  67  // bool.
  68  func Validate(evt *event.E, challenge []byte, relayURL string) (
  69  	ok bool, err error,
  70  ) {
  71  	if evt.Kind != kind.ClientAuthentication.K {
  72  		err = errorf.E(
  73  			"event incorrect kind for auth: %d %s",
  74  			evt.Kind, kind.GetString(evt.Kind),
  75  		)
  76  		return
  77  	}
  78  	if evt.Tags.GetFirst(ChallengeTag) == nil {
  79  		err = errorf.E("challenge tag missing from auth response")
  80  		return
  81  	}
  82  	if !utils.FastEqual(challenge, evt.Tags.GetFirst(ChallengeTag).Value()) {
  83  		err = errorf.E("challenge tag incorrect from auth response")
  84  		return
  85  	}
  86  	var expected, found *url.URL
  87  	if expected, err = parseURL(relayURL); chk.D(err) {
  88  		return
  89  	}
  90  	r := evt.Tags.
  91  		GetFirst(RelayTag).Value()
  92  	if len(r) == 0 {
  93  		err = errorf.E("relay tag missing from auth response")
  94  		return
  95  	}
  96  	if found, err = parseURL(string(r)); chk.D(err) {
  97  		err = errorf.E("error parsing relay url: %s", err)
  98  		return
  99  	}
 100  	// Allow both ws:// and wss:// schemes when behind a reverse proxy
 101  	// This handles cases where the relay expects ws:// but receives wss:// from clients
 102  	// connecting through HTTPS proxies
 103  	if expected.Scheme != found.Scheme {
 104  		// Check if this is a ws/wss scheme mismatch (acceptable behind proxy)
 105  		if (expected.Scheme == "ws" && found.Scheme == "wss") ||
 106  			(expected.Scheme == "wss" && found.Scheme == "ws") {
 107  			// This is acceptable when behind a reverse proxy
 108  			// The client will always send wss:// when connecting through HTTPS
 109  		} else {
 110  			err = errorf.E(
 111  				"HTTP Scheme incorrect: expected '%s' got '%s",
 112  				expected.Scheme, found.Scheme,
 113  			)
 114  			return
 115  		}
 116  	}
 117  	if expected.Host != found.Host {
 118  		err = errorf.E(
 119  			"HTTP Host incorrect: expected '%s' got '%s",
 120  			expected.Host, found.Host,
 121  		)
 122  		return
 123  	}
 124  	if expected.Path != found.Path {
 125  		err = errorf.E(
 126  			"HTTP Path incorrect: expected '%s' got '%s",
 127  			expected.Path, found.Path,
 128  		)
 129  		return
 130  	}
 131  
 132  	now := time.Now().Unix()
 133  	ca := evt.CreatedAt
 134  	if ca > now+10*60 || ca < now-10*60 {
 135  		err = errorf.E(
 136  			"auth event more than 10 minutes before or after current time",
 137  		)
 138  		return
 139  	}
 140  	// save for last, as it is the most expensive operation
 141  	return evt.Verify()
 142  }
 143