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