httpauth.mx raw

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