summaryrefslogtreecommitdiffstats
path: root/internal/auth/validation.go
blob: 11435eec19a79c8fb415adbbc7fbe92e1e34c9f8 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
package auth

import (
	"encoding/base64"
	"encoding/json"
	"fmt"
	"strings"
	"time"

	"northwest.io/muxstr/internal/nostr"
)

// ValidationOptions configures how NIP-98 events are validated.
type ValidationOptions struct {
	// TimestampWindow is the maximum age of events in seconds
	TimestampWindow int64

	// ValidatePayload checks the payload hash if present
	ValidatePayload bool

	// ExpectedURI is the URI that should match the 'u' tag
	ExpectedURI string

	// ExpectedMethod is the method that should match the 'method' tag
	ExpectedMethod string

	// PayloadHash is the expected payload hash (if ValidatePayload is true)
	PayloadHash string
}

// ParseAuthHeader extracts and decodes a NIP-98 event from an Authorization header.
// Expected format: "Nostr <base64-encoded-event-json>"
func ParseAuthHeader(header string) (*nostr.Event, error) {
	if header == "" {
		return nil, fmt.Errorf("empty authorization header")
	}

	// Check for "Nostr " prefix
	if !strings.HasPrefix(header, "Nostr ") {
		return nil, fmt.Errorf("invalid authorization header: must start with 'Nostr '")
	}

	// Extract base64 part
	encoded := strings.TrimPrefix(header, "Nostr ")
	if encoded == "" {
		return nil, fmt.Errorf("empty authorization token")
	}

	// Decode base64
	decoded, err := base64.StdEncoding.DecodeString(encoded)
	if err != nil {
		return nil, fmt.Errorf("invalid base64 encoding: %w", err)
	}

	// Unmarshal event
	var event nostr.Event
	if err := json.Unmarshal(decoded, &event); err != nil {
		return nil, fmt.Errorf("invalid event JSON: %w", err)
	}

	return &event, nil
}

// ValidateAuthEvent validates a NIP-98 auth event according to the spec.
func ValidateAuthEvent(event *nostr.Event, opts ValidationOptions) error {
	// Check event kind
	if event.Kind != 27235 {
		return fmt.Errorf("invalid event kind: expected 27235, got %d", event.Kind)
	}

	// Verify signature
	if !event.Verify() {
		return fmt.Errorf("invalid event signature")
	}

	// Check timestamp (prevent replay attacks)
	now := time.Now().Unix()
	age := now - event.CreatedAt

	if age < 0 {
		return fmt.Errorf("event timestamp is in the future")
	}

	if opts.TimestampWindow > 0 && age > opts.TimestampWindow {
		return fmt.Errorf("event too old: %d seconds (max %d)", age, opts.TimestampWindow)
	}

	// Validate 'u' tag (URL)
	if opts.ExpectedURI != "" {
		uTag := event.Tags.Find("u")
		if uTag == nil {
			return fmt.Errorf("missing 'u' tag in auth event")
		}

		eventURI := uTag.Value()
		if eventURI != opts.ExpectedURI {
			return fmt.Errorf("URI mismatch: expected %s, got %s", opts.ExpectedURI, eventURI)
		}
	}

	// Validate 'method' tag
	if opts.ExpectedMethod != "" {
		methodTag := event.Tags.Find("method")
		if methodTag == nil {
			return fmt.Errorf("missing 'method' tag in auth event")
		}

		eventMethod := methodTag.Value()
		if eventMethod != opts.ExpectedMethod {
			return fmt.Errorf("method mismatch: expected %s, got %s", opts.ExpectedMethod, eventMethod)
		}
	}

	// Validate payload hash if requested
	if opts.ValidatePayload && opts.PayloadHash != "" {
		payloadTag := event.Tags.Find("payload")
		if payloadTag == nil {
			return fmt.Errorf("missing 'payload' tag in auth event")
		}

		eventHash := payloadTag.Value()
		if eventHash != opts.PayloadHash {
			return fmt.Errorf("payload hash mismatch")
		}
	}

	return nil
}

// ExtractPubkey returns the pubkey from a validated auth event.
func ExtractPubkey(event *nostr.Event) string {
	return event.PubKey
}