auth.go raw
1 package blossom
2
3 import (
4 "encoding/base64"
5 "net/http"
6 "strings"
7 "time"
8
9 "next.orly.dev/pkg/lol/chk"
10 "next.orly.dev/pkg/lol/errorf"
11 "next.orly.dev/pkg/nostr/encoders/event"
12 "next.orly.dev/pkg/nostr/encoders/hex"
13 "next.orly.dev/pkg/nostr/encoders/ints"
14 )
15
16 const (
17 // BlossomAuthKind is the Nostr event kind for Blossom authorization events (BUD-01)
18 BlossomAuthKind = 24242
19 // AuthorizationHeader is the HTTP header name for authorization
20 AuthorizationHeader = "Authorization"
21 // NostrAuthPrefix is the prefix for Nostr authorization scheme
22 NostrAuthPrefix = "Nostr"
23 )
24
25 // AuthEvent represents a validated authorization event
26 type AuthEvent struct {
27 Event *event.E
28 Pubkey []byte
29 Verb string
30 Expires int64
31 }
32
33 // ExtractAuthEvent extracts and parses a kind 24242 authorization event from the Authorization header
34 func ExtractAuthEvent(r *http.Request) (ev *event.E, err error) {
35 authHeader := r.Header.Get(AuthorizationHeader)
36 if authHeader == "" {
37 err = errorf.E("missing Authorization header")
38 return
39 }
40
41 // Parse "Nostr <base64>" format
42 if !strings.HasPrefix(authHeader, NostrAuthPrefix+" ") {
43 err = errorf.E("invalid Authorization scheme, expected 'Nostr'")
44 return
45 }
46
47 parts := strings.SplitN(authHeader, " ", 2)
48 if len(parts) != 2 {
49 err = errorf.E("invalid Authorization header format")
50 return
51 }
52
53 var evb []byte
54 if evb, err = base64.StdEncoding.DecodeString(parts[1]); chk.E(err) {
55 return
56 }
57
58 ev = event.New()
59 var rem []byte
60 if rem, err = ev.Unmarshal(evb); chk.E(err) {
61 return
62 }
63
64 if len(rem) > 0 {
65 err = errorf.E("unexpected trailing data in auth event")
66 return
67 }
68
69 return
70 }
71
72 // ValidateAuthEvent validates a kind 24242 authorization event according to BUD-01
73 func ValidateAuthEvent(
74 r *http.Request, verb string, sha256Hash []byte,
75 ) (authEv *AuthEvent, err error) {
76 var ev *event.E
77 if ev, err = ExtractAuthEvent(r); chk.E(err) {
78 return
79 }
80
81 // 1. The kind must be 24242
82 if ev.Kind != BlossomAuthKind {
83 err = errorf.E(
84 "invalid kind %d in authorization event, require %d",
85 ev.Kind, BlossomAuthKind,
86 )
87 return
88 }
89
90 // 2. created_at must be in the past
91 now := time.Now().Unix()
92 if ev.CreatedAt > now {
93 err = errorf.E(
94 "authorization event created_at %d is in the future (now: %d)",
95 ev.CreatedAt, now,
96 )
97 return
98 }
99
100 // 3. Check expiration tag (must be set and in the future)
101 expTags := ev.Tags.GetAll([]byte("expiration"))
102 if len(expTags) == 0 {
103 err = errorf.E("authorization event missing expiration tag")
104 return
105 }
106 if len(expTags) > 1 {
107 err = errorf.E("authorization event has multiple expiration tags")
108 return
109 }
110
111 expInt := ints.New(0)
112 var rem []byte
113 if rem, err = expInt.Unmarshal(expTags[0].Value()); chk.E(err) {
114 return
115 }
116 if len(rem) > 0 {
117 err = errorf.E("unexpected trailing data in expiration tag")
118 return
119 }
120
121 expiration := expInt.Int64()
122 if expiration <= now {
123 err = errorf.E(
124 "authorization event expired: expiration %d <= now %d",
125 expiration, now,
126 )
127 return
128 }
129
130 // 4. The t tag must have a verb matching the intended action
131 tTags := ev.Tags.GetAll([]byte("t"))
132 if len(tTags) == 0 {
133 err = errorf.E("authorization event missing 't' tag")
134 return
135 }
136 if len(tTags) > 1 {
137 err = errorf.E("authorization event has multiple 't' tags")
138 return
139 }
140
141 eventVerb := string(tTags[0].Value())
142 // If verb is non-empty, verify it matches the event verb
143 // Empty verb means "don't check the verb" (used by GetPubkeyFromRequest)
144 if verb != "" && eventVerb != verb {
145 err = errorf.E(
146 "authorization event verb '%s' does not match required verb '%s'",
147 eventVerb, verb,
148 )
149 return
150 }
151
152 // 5. If sha256Hash is provided, verify at least one x tag matches
153 if sha256Hash != nil && len(sha256Hash) > 0 {
154 sha256Hex := hex.Enc(sha256Hash)
155 xTags := ev.Tags.GetAll([]byte("x"))
156 if len(xTags) == 0 {
157 err = errorf.E(
158 "authorization event missing 'x' tag for SHA256 hash %s",
159 sha256Hex,
160 )
161 return
162 }
163
164 found := false
165 for _, xTag := range xTags {
166 if string(xTag.Value()) == sha256Hex {
167 found = true
168 break
169 }
170 }
171
172 if !found {
173 err = errorf.E(
174 "authorization event has no 'x' tag matching SHA256 hash %s",
175 sha256Hex,
176 )
177 return
178 }
179 }
180
181 // 6. Verify event signature
182 var valid bool
183 if valid, err = ev.Verify(); chk.E(err) {
184 return
185 }
186 if !valid {
187 err = errorf.E("authorization event signature verification failed")
188 return
189 }
190
191 authEv = &AuthEvent{
192 Event: ev,
193 Pubkey: ev.Pubkey,
194 Verb: eventVerb,
195 Expires: expiration,
196 }
197
198 return
199 }
200
201 // ValidateAuthEventOptional validates authorization but returns nil if no auth header is present
202 // This is used for endpoints where authorization is optional
203 func ValidateAuthEventOptional(
204 r *http.Request, verb string, sha256Hash []byte,
205 ) (authEv *AuthEvent, err error) {
206 authHeader := r.Header.Get(AuthorizationHeader)
207 if authHeader == "" {
208 // No authorization provided, but that's OK for optional endpoints
209 return nil, nil
210 }
211
212 return ValidateAuthEvent(r, verb, sha256Hash)
213 }
214
215 // ValidateAuthEventForGet validates authorization for GET requests (BUD-01)
216 // GET requests may have either:
217 // - A server tag matching the server URL
218 // - At least one x tag matching the blob hash
219 func ValidateAuthEventForGet(
220 r *http.Request, serverURL string, sha256Hash []byte,
221 ) (authEv *AuthEvent, err error) {
222 var ev *event.E
223 if ev, err = ExtractAuthEvent(r); chk.E(err) {
224 return
225 }
226
227 // Basic validation
228 if authEv, err = ValidateAuthEvent(r, "get", sha256Hash); chk.E(err) {
229 return
230 }
231
232 // For GET requests, check server tag or x tag
233 serverTags := ev.Tags.GetAll([]byte("server"))
234 xTags := ev.Tags.GetAll([]byte("x"))
235
236 // If server tag exists, verify it matches
237 if len(serverTags) > 0 {
238 serverTagValue := string(serverTags[0].Value())
239 if !strings.HasPrefix(serverURL, serverTagValue) {
240 err = errorf.E(
241 "server tag '%s' does not match server URL '%s'",
242 serverTagValue, serverURL,
243 )
244 return
245 }
246 return
247 }
248
249 // Otherwise, verify at least one x tag matches the hash
250 if sha256Hash != nil && len(sha256Hash) > 0 {
251 sha256Hex := hex.Enc(sha256Hash)
252 found := false
253 for _, xTag := range xTags {
254 if string(xTag.Value()) == sha256Hex {
255 found = true
256 break
257 }
258 }
259 if !found {
260 err = errorf.E(
261 "no 'x' tag matching SHA256 hash %s",
262 sha256Hex,
263 )
264 return
265 }
266 } else if len(xTags) == 0 {
267 err = errorf.E(
268 "authorization event must have either 'server' tag or 'x' tag",
269 )
270 return
271 }
272
273 return
274 }
275
276 // ValidateAuthEventForDelete validates authorization for DELETE requests (BUD-02)
277 // If requireServerTag is true, the auth event must include a 'server' tag matching the serverURL
278 // This prevents cross-server replay attacks where a malicious server replays delete auth events
279 func ValidateAuthEventForDelete(
280 r *http.Request, serverURL string, sha256Hash []byte, requireServerTag bool,
281 ) (authEv *AuthEvent, err error) {
282 // First do the standard validation
283 if authEv, err = ValidateAuthEvent(r, "delete", sha256Hash); chk.E(err) {
284 return
285 }
286
287 // If server tag is not required, we're done
288 if !requireServerTag {
289 return
290 }
291
292 // Extract event again to check server tags
293 var ev *event.E
294 if ev, err = ExtractAuthEvent(r); chk.E(err) {
295 return
296 }
297
298 // Check for server tag
299 serverTags := ev.Tags.GetAll([]byte("server"))
300 if len(serverTags) == 0 {
301 err = errorf.E(
302 "delete authorization requires 'server' tag for replay protection",
303 )
304 return
305 }
306
307 // Verify at least one server tag matches
308 found := false
309 for _, serverTag := range serverTags {
310 serverTagValue := string(serverTag.Value())
311 if strings.HasPrefix(serverURL, serverTagValue) {
312 found = true
313 break
314 }
315 }
316
317 if !found {
318 err = errorf.E(
319 "no 'server' tag matches this server URL '%s'",
320 serverURL,
321 )
322 return
323 }
324
325 return
326 }
327
328 // GetPubkeyFromRequest extracts pubkey from Authorization header if present
329 func GetPubkeyFromRequest(r *http.Request) (pubkey []byte, err error) {
330 authHeader := r.Header.Get(AuthorizationHeader)
331 if authHeader == "" {
332 return nil, nil
333 }
334
335 authEv, err := ValidateAuthEventOptional(r, "", nil)
336 if err != nil {
337 // If validation fails, return empty pubkey but no error
338 // This allows endpoints to work without auth
339 return nil, nil
340 }
341
342 if authEv != nil {
343 return authEv.Pubkey, nil
344 }
345
346 return nil, nil
347 }
348