nip42.go raw
1 package auth
2
3 import (
4 "crypto/rand"
5 "encoding/base64"
6 "net/url"
7 "strings"
8 "time"
9
10 "next.orly.dev/pkg/nostr/encoders/event"
11 "next.orly.dev/pkg/nostr/encoders/kind"
12 "next.orly.dev/pkg/nostr/encoders/tag"
13 "next.orly.dev/pkg/nostr/utils"
14 "next.orly.dev/pkg/nostr/utils/normalize"
15 "next.orly.dev/pkg/lol/chk"
16 "next.orly.dev/pkg/lol/errorf"
17 )
18
19 // GenerateChallenge creates a reasonable, 16-byte base64 challenge string
20 func GenerateChallenge() (b []byte) {
21 bb := make([]byte, 12)
22 b = make([]byte, 16)
23 _, _ = rand.Read(bb)
24 base64.URLEncoding.Encode(b, bb)
25 return
26 }
27
28 // CreateUnsigned creates an event which should be sent via an "AUTH" command.
29 // If the authentication succeeds, the user will be authenticated as a pubkey.
30 func CreateUnsigned(pubkey, challenge []byte, relayURL string) (ev *event.E) {
31 return &event.E{
32 Pubkey: pubkey,
33 CreatedAt: time.Now().Unix(),
34 Kind: kind.ClientAuthentication.K,
35 Tags: tag.NewS(
36 tag.NewFromAny("relay", relayURL),
37 tag.NewFromAny("challenge", string(challenge)),
38 ),
39 }
40 }
41
42 // helper function for ValidateAuthEvent.
43 // Normalizes the URL to ensure it has a proper ws:// or wss:// scheme before parsing.
44 func parseURL(input string) (*url.URL, error) {
45 // Use normalize.URL to ensure the URL has a proper scheme
46 // This handles cases like "relay.example.com:3334" which url.Parse
47 // would incorrectly interpret as scheme="relay.example.com" opaque="3334"
48 normalized := string(normalize.URL(input))
49 return url.Parse(
50 strings.ToLower(
51 strings.TrimSuffix(normalized, "/"),
52 ),
53 )
54 }
55
56 var (
57 // ChallengeTag is the tag for the challenge in a NIP-42 auth event
58 // (prevents relay attacks).
59 ChallengeTag = []byte("challenge")
60 // RelayTag is the relay tag for a NIP-42 auth event (prevents cross-server
61 // attacks).
62 RelayTag = []byte("relay")
63 )
64
65 // Validate checks whether an event is a valid NIP-42 event for a given
66 // challenge and relayURL. The result of the validation is encoded in the ok
67 // bool.
68 func Validate(evt *event.E, challenge []byte, relayURL string) (
69 ok bool, err error,
70 ) {
71 if evt.Kind != kind.ClientAuthentication.K {
72 err = errorf.E(
73 "event incorrect kind for auth: %d %s",
74 evt.Kind, kind.GetString(evt.Kind),
75 )
76 return
77 }
78 if evt.Tags.GetFirst(ChallengeTag) == nil {
79 err = errorf.E("challenge tag missing from auth response")
80 return
81 }
82 if !utils.FastEqual(challenge, evt.Tags.GetFirst(ChallengeTag).Value()) {
83 err = errorf.E("challenge tag incorrect from auth response")
84 return
85 }
86 var expected, found *url.URL
87 if expected, err = parseURL(relayURL); chk.D(err) {
88 return
89 }
90 r := evt.Tags.
91 GetFirst(RelayTag).Value()
92 if len(r) == 0 {
93 err = errorf.E("relay tag missing from auth response")
94 return
95 }
96 if found, err = parseURL(string(r)); chk.D(err) {
97 err = errorf.E("error parsing relay url: %s", err)
98 return
99 }
100 // Allow both ws:// and wss:// schemes when behind a reverse proxy
101 // This handles cases where the relay expects ws:// but receives wss:// from clients
102 // connecting through HTTPS proxies
103 if expected.Scheme != found.Scheme {
104 // Check if this is a ws/wss scheme mismatch (acceptable behind proxy)
105 if (expected.Scheme == "ws" && found.Scheme == "wss") ||
106 (expected.Scheme == "wss" && found.Scheme == "ws") {
107 // This is acceptable when behind a reverse proxy
108 // The client will always send wss:// when connecting through HTTPS
109 } else {
110 err = errorf.E(
111 "HTTP Scheme incorrect: expected '%s' got '%s",
112 expected.Scheme, found.Scheme,
113 )
114 return
115 }
116 }
117 if expected.Host != found.Host {
118 err = errorf.E(
119 "HTTP Host incorrect: expected '%s' got '%s",
120 expected.Host, found.Host,
121 )
122 return
123 }
124 if expected.Path != found.Path {
125 err = errorf.E(
126 "HTTP Path incorrect: expected '%s' got '%s",
127 expected.Path, found.Path,
128 )
129 return
130 }
131
132 now := time.Now().Unix()
133 ca := evt.CreatedAt
134 if ca > now+10*60 || ca < now-10*60 {
135 err = errorf.E(
136 "auth event more than 10 minutes before or after current time",
137 )
138 return
139 }
140 // save for last, as it is the most expensive operation
141 return evt.Verify()
142 }
143