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