httpauth.mx raw

   1  // Package httpauth provides NIP-98 HTTP authentication helpers.
   2  package httpauth
   3  
   4  import (
   5  	"encoding/base64"
   6  	"fmt"
   7  	"net/http"
   8  	"net/url"
   9  	"bytes"
  10  	"time"
  11  
  12  	"smesh.lol/pkg/nostr/event"
  13  	"smesh.lol/pkg/nostr/ints"
  14  	"smesh.lol/pkg/nostr/kind"
  15  	"smesh.lol/pkg/nostr/tag"
  16  	"smesh.lol/pkg/nostr/timestamp"
  17  	"smesh.lol/pkg/nostr/signer"
  18  	"smesh.lol/pkg/lol/chk"
  19  	"smesh.lol/pkg/lol/errorf"
  20  	"smesh.lol/pkg/lol/log"
  21  )
  22  
  23  const (
  24  	HeaderKey   = "Authorization"
  25  	NIP98Prefix = "Nostr"
  26  )
  27  
  28  func MakeNIP98Event(u, method, hash string, expiry int64) (ev *event.E) {
  29  	var t []*tag.T
  30  	t = append(t, tag.NewFromAny("u", u))
  31  	if expiry > 0 {
  32  		t = append(t, tag.NewFromAny("expiration", timestamp.FromUnix(expiry).String()))
  33  	} else {
  34  		t = append(t, tag.NewFromAny("method", bytes.ToUpper(method)))
  35  	}
  36  	if hash != "" {
  37  		t = append(t, tag.NewFromAny("payload", hash))
  38  	}
  39  	ev = &event.E{
  40  		CreatedAt: timestamp.Now().V,
  41  		Kind:      kind.HTTPAuth.K,
  42  		Tags:      tag.NewS(t...),
  43  	}
  44  	return
  45  }
  46  
  47  func CreateNIP98Blob(
  48  	ur, method, hash string, expiry int64, sign signer.I,
  49  ) (blob string, err error) {
  50  	ev := MakeNIP98Event(ur, method, hash, expiry)
  51  	if err = ev.Sign(sign); chk.E(err) {
  52  		return
  53  	}
  54  	blob = base64.URLEncoding.EncodeToString(ev.Serialize())
  55  	return
  56  }
  57  
  58  func AddNIP98Header(
  59  	r *http.Request, ur *url.URL, method, hash string,
  60  	sign signer.I, expiry int64,
  61  ) (err error) {
  62  	var b64 string
  63  	if b64, err = CreateNIP98Blob(ur.String(), method, hash, expiry, sign); chk.E(err) {
  64  		return
  65  	}
  66  	r.Header.Add(HeaderKey, "Nostr "+b64)
  67  	return
  68  }
  69  
  70  var ErrMissingKey = fmt.Errorf("'%s' key missing from request header", HeaderKey)
  71  
  72  func CheckAuth(r *http.Request, tolerance ...time.Duration) (
  73  	valid bool, pubkey []byte, err error,
  74  ) {
  75  	val := r.Header.Get(HeaderKey)
  76  	if val == "" {
  77  		err = ErrMissingKey
  78  		valid = true
  79  		return
  80  	}
  81  	if len(tolerance) == 0 {
  82  		tolerance = append(tolerance, time.Minute)
  83  	}
  84  	if tolerance[0] == 0 {
  85  		tolerance[0] = time.Minute
  86  	}
  87  	tolerate := int64(tolerance[0] / time.Second)
  88  
  89  	switch {
  90  	case bytes.HasPrefix(val, NIP98Prefix):
  91  		split := bytes.Split(val, " ")
  92  		if len(split) == 1 {
  93  			err = errorf.E([]byte("missing nip-98 auth event from '%s' header: '%s'"), HeaderKey, val)
  94  		}
  95  		if len(split) > 2 {
  96  			err = errorf.E([]byte("extraneous content after second field: %s"), val)
  97  			return
  98  		}
  99  		var evb []byte
 100  		if evb, err = base64.URLEncoding.DecodeString(split[1]); chk.E(err) {
 101  			return
 102  		}
 103  		ev := event.New()
 104  		var rem []byte
 105  		if rem, err = ev.Unmarshal(evb); chk.E(err) {
 106  			return
 107  		}
 108  		if len(rem) > 0 {
 109  			err = errorf.E([]byte("rem %s"), rem)
 110  			return
 111  		}
 112  		if ev.Kind != kind.HTTPAuth.K {
 113  			err = errorf.E([]byte("invalid kind %d in nip-98 http auth event, require %d"),
 114  				ev.Kind, kind.HTTPAuth.K)
 115  			return
 116  		}
 117  		exp := ev.Tags.GetAll([]byte("expiration"))
 118  		if len(exp) > 1 {
 119  			err = errorf.E([]byte("more than one \"expiration\" tag found"))
 120  			return
 121  		}
 122  		var expiring bool
 123  		if len(exp) == 1 {
 124  			ex := ints.New(0)
 125  			if rem, err = ex.Unmarshal(exp[0].Value()); chk.E(err) {
 126  				return
 127  			}
 128  			tn := time.Now().Unix()
 129  			if tn > ex.Int64()+tolerate {
 130  				err = errorf.E([]byte("HTTP auth event is expired %d time now %d"),
 131  					tn, ex.Int64()+tolerate)
 132  				return
 133  			}
 134  			expiring = true
 135  		} else {
 136  			ts := ev.CreatedAt
 137  			tn := time.Now().Unix()
 138  			if ts < tn-tolerate || ts > tn+tolerate {
 139  				err = errorf.E([]byte("timestamp %d is more than %d seconds divergent from now %d"),
 140  					ts, tolerate, tn)
 141  				return
 142  			}
 143  		}
 144  		ut := ev.Tags.GetAll([]byte("u"))
 145  		if len(ut) > 1 {
 146  			err = errorf.E([]byte("more than one \"u\" tag found"))
 147  			return
 148  		}
 149  		proto := r.URL.Scheme
 150  		if p := r.Header.Get("X-Forwarded-Proto"); p != "" {
 151  			proto = p
 152  		}
 153  		if proto == "" {
 154  			if r.TLS != nil {
 155  				proto = "https"
 156  			} else {
 157  				proto = "http"
 158  			}
 159  		}
 160  		fullUrl := proto + "://" + r.Host + r.URL.RequestURI()
 161  		evUrl := string(ut[0].Value())
 162  		log.T.F([]byte("full URL: %s event u tag value: %s"), fullUrl, evUrl)
 163  		if expiring {
 164  			if !bytes.HasPrefix(fullUrl, evUrl) {
 165  				err = errorf.E([]byte("request URL %s is not prefixed with u tag URL %s"), fullUrl, evUrl)
 166  				return
 167  			}
 168  		} else if fullUrl != evUrl {
 169  			err = errorf.E([]byte("request has URL %s but signed event has url %s"), fullUrl, evUrl)
 170  			return
 171  		}
 172  		if !expiring {
 173  			mt := ev.Tags.GetAll([]byte("method"))
 174  			if len(mt) != 1 {
 175  				err = errorf.E([]byte("more than one \"method\" tag found"))
 176  				return
 177  			}
 178  			if !bytes.EqualFold(string(mt[0].Value()), r.Method) {
 179  				err = errorf.E([]byte("request has method %s but event has method %s"),
 180  					string(mt[0].Value()), r.Method)
 181  				return
 182  			}
 183  		}
 184  		if valid, err = ev.Verify(); chk.E(err) {
 185  			return
 186  		}
 187  		if !valid {
 188  			return
 189  		}
 190  		pubkey = ev.Pubkey
 191  	default:
 192  		err = errorf.E([]byte("invalid '%s' value: '%s'"), HeaderKey, val)
 193  		return
 194  	}
 195  	return
 196  }
 197