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