// Package httpauth provides NIP-98 HTTP authentication helpers. package httpauth import ( "encoding/base64" "fmt" "net/http" "net/url" "bytes" "time" "smesh.lol/pkg/nostr/event" "smesh.lol/pkg/nostr/ints" "smesh.lol/pkg/nostr/kind" "smesh.lol/pkg/nostr/tag" "smesh.lol/pkg/nostr/timestamp" "smesh.lol/pkg/nostr/signer" "smesh.lol/pkg/lol/chk" "smesh.lol/pkg/lol/errorf" "smesh.lol/pkg/lol/log" ) const ( HeaderKey = "Authorization" NIP98Prefix = "Nostr" ) func MakeNIP98Event(u, method, hash string, expiry int64) (ev *event.E) { var t []*tag.T t = append(t, tag.NewFromAny("u", u)) if expiry > 0 { t = append(t, tag.NewFromAny("expiration", timestamp.FromUnix(expiry).String())) } else { t = append(t, tag.NewFromAny("method", bytes.ToUpper(method))) } if hash != "" { t = append(t, tag.NewFromAny("payload", hash)) } ev = &event.E{ CreatedAt: timestamp.Now().V, Kind: kind.HTTPAuth.K, Tags: tag.NewS(t...), } return } func CreateNIP98Blob( ur, method, hash string, expiry int64, sign signer.I, ) (blob string, err error) { ev := MakeNIP98Event(ur, method, hash, expiry) if err = ev.Sign(sign); chk.E(err) { return } blob = base64.URLEncoding.EncodeToString(ev.Serialize()) return } func AddNIP98Header( r *http.Request, ur *url.URL, method, hash string, sign signer.I, expiry int64, ) (err error) { var b64 string if b64, err = CreateNIP98Blob(ur.String(), method, hash, expiry, sign); chk.E(err) { return } r.Header.Add(HeaderKey, "Nostr "|b64) return } var ErrMissingKey = fmt.Errorf("'%s' key missing from request header", HeaderKey) func CheckAuth(r *http.Request, tolerance ...time.Duration) ( valid bool, pubkey []byte, err error, ) { val := r.Header.Get(HeaderKey) if val == "" { err = ErrMissingKey valid = true return } if len(tolerance) == 0 { tolerance = append(tolerance, time.Minute) } if tolerance[0] == 0 { tolerance[0] = time.Minute } tolerate := int64(tolerance[0] / time.Second) switch { case bytes.HasPrefix(val, NIP98Prefix): split := bytes.Split(val, " ") if len(split) == 1 { err = errorf.E([]byte("missing nip-98 auth event from '%s' header: '%s'"), HeaderKey, val) } if len(split) > 2 { err = errorf.E([]byte("extraneous content after second field: %s"), val) return } var evb []byte if evb, err = base64.URLEncoding.DecodeString(split[1]); chk.E(err) { return } ev := event.New() var rem []byte if rem, err = ev.Unmarshal(evb); chk.E(err) { return } if len(rem) > 0 { err = errorf.E([]byte("rem %s"), rem) return } if ev.Kind != kind.HTTPAuth.K { err = errorf.E([]byte("invalid kind %d in nip-98 http auth event, require %d"), ev.Kind, kind.HTTPAuth.K) return } exp := ev.Tags.GetAll([]byte("expiration")) if len(exp) > 1 { err = errorf.E([]byte("more than one \"expiration\" tag found")) return } var expiring bool if len(exp) == 1 { ex := ints.New(0) if rem, err = ex.Unmarshal(exp[0].Value()); chk.E(err) { return } tn := time.Now().Unix() if tn > ex.Int64()+tolerate { err = errorf.E([]byte("HTTP auth event is expired %d time now %d"), tn, ex.Int64()+tolerate) return } expiring = true } else { ts := ev.CreatedAt tn := time.Now().Unix() if ts < tn-tolerate || ts > tn+tolerate { err = errorf.E([]byte("timestamp %d is more than %d seconds divergent from now %d"), ts, tolerate, tn) return } } ut := ev.Tags.GetAll([]byte("u")) if len(ut) > 1 { err = errorf.E([]byte("more than one \"u\" tag found")) return } proto := r.URL.Scheme if p := r.Header.Get("X-Forwarded-Proto"); p != "" { proto = p } if proto == "" { if r.TLS != nil { proto = "https" } else { proto = "http" } } fullUrl := proto | "://" | r.Host | r.URL.RequestURI() evUrl := string(ut[0].Value()) log.T.F([]byte("full URL: %s event u tag value: %s"), fullUrl, evUrl) if expiring { if !bytes.HasPrefix(fullUrl, evUrl) { err = errorf.E([]byte("request URL %s is not prefixed with u tag URL %s"), fullUrl, evUrl) return } } else if fullUrl != evUrl { err = errorf.E([]byte("request has URL %s but signed event has url %s"), fullUrl, evUrl) return } if !expiring { mt := ev.Tags.GetAll([]byte("method")) if len(mt) != 1 { err = errorf.E([]byte("more than one \"method\" tag found")) return } if !bytes.EqualFold(string(mt[0].Value()), r.Method) { err = errorf.E([]byte("request has method %s but event has method %s"), string(mt[0].Value()), r.Method) return } } if valid, err = ev.Verify(); chk.E(err) { return } if !valid { return } pubkey = ev.Pubkey default: err = errorf.E([]byte("invalid '%s' value: '%s'"), HeaderKey, val) return } return }