tinyrelayfile
tinyrelay/protocol/auth/auth.go/

protocol/auth/auth.go raw
1package auth
2
3import (
4 "crypto/rand"
5 "crypto/sha256"
6 "encoding/base64"
7 "encoding/hex"
8 "errors"
9 "fmt"
10 "net/url"
11 "strings"
12 "sync"
13 "time"
14
15 "github.com/FelineStateMachine/tinyrelay/protocol/nostr"
16)
17
18const tokenSkew = time.Minute
19
20var ErrMissingAuthorization = errors.New("auth-required: missing Nostr Authorization header")
21
22type Validator struct {
23 now func() time.Time
24 mu sync.Mutex
25 seen map[string]time.Time
26}
27
28// NewValidator uses now to check proof freshness and track replay windows.
29// A nil clock selects the system clock.
30func NewValidator(now func() time.Time) *Validator {
31 if now == nil {
32 now = time.Now
33 }
34 return &Validator{now: now, seen: make(map[string]time.Time)}
35}
36
37// VerifyNIP98 validates a kind 27235 request proof against the exact URL,
38// method and body supplied by the caller. Successfully verified event IDs are
39// retained for the one-minute replay window.
40func (v *Validator) VerifyNIP98(header, rawURL, method, body string) (nostr.Event, error) {
41 e, err := decodeToken(header, "NIP-98")
42 if err != nil {
43 return nostr.Event{}, err
44 }
45 if err := nostr.Validate(e); err != nil {
46 return nostr.Event{}, authError(err.Error())
47 }
48 if e.Kind != 27235 {
49 return nostr.Event{}, authError("token must be kind 27235")
50 }
51 now := v.now()
52 if outsideSkew(now, e.CreatedAt) {
53 return nostr.Event{}, authError("token expired")
54 }
55 if err := sameRequestURL(nostr.Tag(e, "u"), rawURL); err != nil {
56 return nostr.Event{}, err
57 }
58 if strings.ToUpper(nostr.Tag(e, "method")) != strings.ToUpper(method) {
59 return nostr.Event{}, authError("token was signed for another method")
60 }
61 if body != "" && nostr.Tag(e, "payload") != hashBytes([]byte(body)) {
62 return nostr.Event{}, authError("token payload hash does not match the body")
63 }
64 if err := v.mark(e.ID, now); err != nil {
65 return nostr.Event{}, err
66 }
67 return e, nil
68}
69
70// VerifyBlossom validates a BUD-11 authorization for action. It accepts the
71// protocol's reusable authorization form and checks its action and expiry.
72func (v *Validator) VerifyBlossom(header, action string) (nostr.Event, error) {
73 return v.verifyBlossom(header, action, "", "", false)
74}
75
76// VerifyBlossomRequest validates a BUD-11 authorization with optional server
77// and blob-hash scope. Upload, delete, mirror and media actions require an x
78// tag when this scoped form is used.
79func (v *Validator) VerifyBlossomRequest(header, action, server, blobHash string) (nostr.Event, error) {
80 return v.verifyBlossom(header, action, server, blobHash, true)
81}
82
83// IsBlossomAuthorization reports whether an Authorization header contains a
84// signed BUD-11 event. Malformed tokens return false so callers can produce
85// their normal authentication error through the protocol-specific verifier.
86func IsBlossomAuthorization(header string) bool {
87 e, err := decodeToken(header, "Blossom")
88 return err == nil && e.Kind == 24242
89}
90
91func (v *Validator) verifyBlossom(header, action, server, blobHash string, strict bool) (nostr.Event, error) {
92 e, err := decodeToken(header, "Blossom")
93 if err != nil {
94 return nostr.Event{}, err
95 }
96 if err := nostr.Validate(e); err != nil {
97 return nostr.Event{}, authError(err.Error())
98 }
99 if e.Kind != 24242 {
100 return nostr.Event{}, authError("token must be kind 24242")
101 }
102 if nostr.Tag(e, "t") != action {
103 return nostr.Event{}, authError(fmt.Sprintf("token is not for %s", action))
104 }
105 if strict && server != "" && !blossomServerScope(e, server) {
106 return nostr.Event{}, authError("token is not scoped to this server")
107 }
108 now := v.now()
109 expiration := nostr.Expiration(e)
110 if expiration == 0 || expiration <= now.Unix() {
111 return nostr.Event{}, authError("token expired")
112 }
113 // BUD-11 authorization timestamps may not be from the future. The
114 // expiration tag, rather than an arbitrary maximum age, controls how long
115 // an otherwise valid token remains usable.
116 if e.CreatedAt > now.Unix() {
117 return nostr.Event{}, authError("token is from the future")
118 }
119 if strict && requiresBlobScope(action) && len(nostr.TagValues(e, "x")) == 0 {
120 return nostr.Event{}, authError("token must include an x tag")
121 }
122 if strict && blobHash != "" && len(nostr.TagValues(e, "x")) > 0 && !contains(nostr.TagValues(e, "x"), blobHash) {
123 return nostr.Event{}, authError("token x tag does not name this blob")
124 }
125 return e, nil
126}
127
128func requiresBlobScope(action string) bool {
129 switch action {
130 case "upload", "delete", "mirror", "media":
131 return true
132 default:
133 return false
134 }
135}
136
137func blossomServerScope(e nostr.Event, server string) bool {
138 for _, value := range nostr.TagValues(e, "server") {
139 if strings.EqualFold(strings.TrimSpace(value), strings.TrimSpace(server)) {
140 return true
141 }
142 }
143 return len(nostr.TagValues(e, "server")) == 0
144}
145
146func (v *Validator) WhoAsks(header, rawURL, method, body, action, blobHash string) ([]string, error) {
147 if strings.TrimSpace(header) == "" {
148 return nil, nil
149 }
150 e, err := decodeToken(header, "authorization")
151 if err != nil {
152 return nil, err
153 }
154 if e.Kind == 24242 {
155 if action == "" {
156 return nil, authError("this door takes a NIP-98 signature")
157 }
158 e, err = v.VerifyBlossom(header, action)
159 if err != nil {
160 return nil, err
161 }
162 xs := nostr.TagValues(e, "x")
163 if blobHash != "" && len(xs) > 0 && !contains(xs, blobHash) {
164 return nil, authError("token x tag does not name this blob")
165 }
166 return []string{e.PubKey}, nil
167 }
168 e, err = v.VerifyNIP98(header, rawURL, method, body)
169 if err != nil {
170 return nil, err
171 }
172 return []string{e.PubKey}, nil
173}
174
175// ValidateBlobPayload checks the hash binding on a token that was already
176// authenticated by WhoAsks. It deliberately does not mark the event as seen a
177// second time: upload authorization and the streamed body validator inspect
178// the same proof at two different points in the request.
179func (v *Validator) ValidateBlobPayload(header, actualSHA string) error {
180 e, err := decodeToken(header, "authorization")
181 if err != nil {
182 return err
183 }
184 if err := nostr.Validate(e); err != nil {
185 return authError(err.Error())
186 }
187 switch e.Kind {
188 case 24242:
189 if values := nostr.TagValues(e, "x"); len(values) > 0 && !contains(values, actualSHA) {
190 return authError("token x tag does not name this blob")
191 }
192 case 27235:
193 if nostr.Tag(e, "payload") != actualSHA {
194 return authError("token payload hash does not match the blob hash")
195 }
196 default:
197 return authError("token must be kind 24242 or 27235")
198 }
199 return nil
200}
201
202// ValidateBlossomPayload rechecks the content hash for a Blossom-authenticated
203// operation after a streaming body has been materialized. It does not mark the
204// authorization event as seen a second time.
205func (v *Validator) ValidateBlossomPayload(header, action, actualSHA string) error {
206 e, err := v.VerifyBlossom(header, action)
207 if err != nil {
208 return err
209 }
210 if values := nostr.TagValues(e, "x"); len(values) > 0 && !contains(values, actualSHA) {
211 return authError("token x tag does not name this blob")
212 }
213 return nil
214}
215
216type ChallengeManager struct {
217 relayURL string
218 now func() time.Time
219 mu sync.Mutex
220 active map[string]time.Time
221}
222
223// NewChallengeManager creates a NIP-42 challenge store for relayURL. Issued
224// challenges are single-use and expire with the validator replay window.
225func NewChallengeManager(relayURL string, now func() time.Time) *ChallengeManager {
226 if now == nil {
227 now = time.Now
228 }
229 return &ChallengeManager{relayURL: relayURL, now: now, active: make(map[string]time.Time)}
230}
231
232// Issue creates and stores a cryptographically random NIP-42 challenge.
233func (m *ChallengeManager) Issue() (string, error) {
234 raw := make([]byte, 32)
235 if _, err := rand.Read(raw); err != nil {
236 return "", fmt.Errorf("create NIP-42 challenge: %w", err)
237 }
238 challenge := hex.EncodeToString(raw)
239 now := m.now()
240 m.mu.Lock()
241 m.prune(now)
242 m.active[challenge] = now.Add(tokenSkew)
243 m.mu.Unlock()
244 return challenge, nil
245}
246
247// Verify validates and consumes a NIP-42 AUTH event for the configured relay.
248func (m *ChallengeManager) Verify(e nostr.Event) error {
249 if err := nostr.Validate(e); err != nil {
250 return authError(err.Error())
251 }
252 if e.Kind != 22242 {
253 return authError("AUTH event must be kind 22242")
254 }
255 now := m.now()
256 if outsideSkew(now, e.CreatedAt) {
257 return authError("AUTH event timestamp expired")
258 }
259 if nostr.Tag(e, "relay") != m.relayURL {
260 return authError("AUTH event names another relay")
261 }
262 challenge := nostr.Tag(e, "challenge")
263 m.mu.Lock()
264 defer m.mu.Unlock()
265 m.prune(now)
266 if _, ok := m.active[challenge]; !ok {
267 return authError("AUTH challenge is unknown or already used")
268 }
269 delete(m.active, challenge)
270 return nil
271}
272
273func (m *ChallengeManager) prune(now time.Time) {
274 for challenge, expires := range m.active {
275 if !expires.After(now) {
276 delete(m.active, challenge)
277 }
278 }
279}
280
281func (v *Validator) mark(id string, now time.Time) error {
282 v.mu.Lock()
283 defer v.mu.Unlock()
284 for seenID, at := range v.seen {
285 if now.Sub(at) > tokenSkew {
286 delete(v.seen, seenID)
287 }
288 }
289 if _, ok := v.seen[id]; ok {
290 return authError("token replay detected")
291 }
292 v.seen[id] = now
293 return nil
294}
295
296func decodeToken(header, tokenName string) (nostr.Event, error) {
297 parts := strings.Fields(header)
298 if len(parts) != 2 || !strings.EqualFold(parts[0], "Nostr") {
299 return nostr.Event{}, ErrMissingAuthorization
300 }
301 raw, err := decodeBase64(parts[1])
302 if err != nil {
303 return nostr.Event{}, authError(fmt.Sprintf("malformed %s token", tokenName))
304 }
305 e, err := nostr.Parse(raw)
306 if err != nil {
307 return nostr.Event{}, authError(err.Error())
308 }
309 return e, nil
310}
311
312func decodeBase64(value string) ([]byte, error) {
313 for _, encoding := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} {
314 if raw, err := encoding.DecodeString(value); err == nil {
315 return raw, nil
316 }
317 }
318 return nil, errors.New("invalid base64")
319}
320
321func sameRequestURL(signed, requested string) error {
322 want, ok := normalizedRequestURL(requested)
323 if !ok {
324 return authError("token was signed for another URL")
325 }
326 got, ok := normalizedRequestURL(signed)
327 if !ok || got != want {
328 return authError("token was signed for another URL")
329 }
330 return nil
331}
332
333func normalizedRequestURL(raw string) (string, bool) {
334 u, err := url.Parse(strings.TrimSpace(raw))
335 if err != nil || u.Scheme == "" || u.Host == "" || u.User != nil || u.Fragment != "" || u.Opaque != "" {
336 return "", false
337 }
338 path := u.EscapedPath()
339 if path == "" {
340 path = "/"
341 }
342 // URL.Host preserves a possibly meaningful port while hostname matching is
343 // case insensitive. Query strings remain part of the NIP-98 binding.
344 host := strings.ToLower(u.Hostname())
345 if port := u.Port(); port != "" {
346 host += ":" + port
347 }
348 return strings.ToLower(u.Scheme) + "://" + host + path + "?" + u.RawQuery, true
349}
350
351func outsideSkew(now time.Time, createdAt int64) bool {
352 delta := now.Unix() - createdAt
353 return delta > int64(tokenSkew/time.Second) || delta < -int64(tokenSkew/time.Second)
354}
355
356func hashBytes(value []byte) string {
357 sum := sha256.Sum256(value)
358 return hex.EncodeToString(sum[:])
359}
360
361func authError(reason string) error { return fmt.Errorf("auth-required: %s", reason) }
362func contains(values []string, value string) bool {
363 for _, candidate := range values {
364 if candidate == value {
365 return true
366 }
367 }
368 return false
369}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs