clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/ui-updates
tinyrelay / protocol / auth / auth.go
auth.go 11.7K
1package auth23import (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"1415 "github.com/FelineStateMachine/tinyrelay/protocol/nostr"16)1718const tokenSkew = time.Minute1920var ErrMissingAuthorization = errors.New("auth-required: missing Nostr Authorization header")2122type Validator struct {23 now func() time.Time24 mu sync.Mutex25 seen map[string]time.Time26}2728// 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.Now33 }34 return &Validator{now: now, seen: make(map[string]time.Time)}35}3637// VerifyNIP98 validates a kind 27235 request proof against the exact URL,38// method and body supplied by the caller. A payload tag must match even when39// the body is empty. Successfully verified event IDs are retained for the40// one-minute replay window.41func (v *Validator) VerifyNIP98(header, rawURL, method, body string) (nostr.Event, error) {42 return v.verifyNIP98(header, rawURL, method, body, true)43}4445// VerifyNIP98Deferred authenticates a proof before a streamed body is46// read. Callers must check the payload tag against the resulting content hash47// before accepting the body, such as with ValidateBlobPayload for uploads.48func (v *Validator) VerifyNIP98Deferred(header, rawURL, method string) (nostr.Event, error) {49 return v.verifyNIP98(header, rawURL, method, "", false)50}5152func (v *Validator) verifyNIP98(header, rawURL, method, body string, checkPayload bool) (nostr.Event, error) {53 e, err := decodeToken(header, "NIP-98")54 if err != nil {55 return nostr.Event{}, err56 }57 if err := nostr.Validate(e); err != nil {58 return nostr.Event{}, authError(err.Error())59 }60 if e.Kind != 27235 {61 return nostr.Event{}, authError("token must be kind 27235")62 }63 now := v.now()64 if outsideSkew(now, e.CreatedAt) {65 return nostr.Event{}, authError("token expired")66 }67 if err := sameRequestURL(nostr.Tag(e, "u"), rawURL); err != nil {68 return nostr.Event{}, err69 }70 if strings.ToUpper(nostr.Tag(e, "method")) != strings.ToUpper(method) {71 return nostr.Event{}, authError("token was signed for another method")72 }73 payload := nostr.Tag(e, "payload")74 if checkPayload && (body != "" || payload != "") && payload != hashBytes([]byte(body)) {75 return nostr.Event{}, authError("token payload hash does not match the body")76 }77 if err := v.mark(e.ID, now); err != nil {78 return nostr.Event{}, err79 }80 return e, nil81}8283// VerifyBlossom validates a BUD-11 authorization for action. It accepts the84// protocol's reusable authorization form and checks its action and expiry.85func (v *Validator) VerifyBlossom(header, action string) (nostr.Event, error) {86 return v.verifyBlossom(header, action, "", "", false)87}8889// VerifyBlossomRequest validates a BUD-11 authorization with optional server90// and blob-hash scope. Upload, delete, mirror and media actions require an x91// tag when this scoped form is used.92func (v *Validator) VerifyBlossomRequest(header, action, server, blobHash string) (nostr.Event, error) {93 return v.verifyBlossom(header, action, server, blobHash, true)94}9596// IsBlossomAuthorization reports whether an Authorization header contains a97// signed BUD-11 event. Malformed tokens return false so callers can produce98// their normal authentication error through the protocol-specific verifier.99func IsBlossomAuthorization(header string) bool {100 e, err := decodeToken(header, "Blossom")101 return err == nil && e.Kind == 24242102}103104func (v *Validator) verifyBlossom(header, action, server, blobHash string, strict bool) (nostr.Event, error) {105 e, err := decodeToken(header, "Blossom")106 if err != nil {107 return nostr.Event{}, err108 }109 if err := nostr.Validate(e); err != nil {110 return nostr.Event{}, authError(err.Error())111 }112 if e.Kind != 24242 {113 return nostr.Event{}, authError("token must be kind 24242")114 }115 if nostr.Tag(e, "t") != action {116 return nostr.Event{}, authError(fmt.Sprintf("token is not for %s", action))117 }118 if strict && server != "" && !blossomServerScope(e, server) {119 return nostr.Event{}, authError("token is not scoped to this server")120 }121 now := v.now()122 expiration := nostr.Expiration(e)123 if expiration == 0 || expiration <= now.Unix() {124 return nostr.Event{}, authError("token expired")125 }126 // BUD-11 authorization timestamps may not be from the future. The127 // expiration tag, rather than an arbitrary maximum age, controls how long128 // an otherwise valid token remains usable.129 if e.CreatedAt > now.Unix() {130 return nostr.Event{}, authError("token is from the future")131 }132 if strict && requiresBlobScope(action) && len(nostr.TagValues(e, "x")) == 0 {133 return nostr.Event{}, authError("token must include an x tag")134 }135 if strict && blobHash != "" && len(nostr.TagValues(e, "x")) > 0 && !contains(nostr.TagValues(e, "x"), blobHash) {136 return nostr.Event{}, authError("token x tag does not name this blob")137 }138 return e, nil139}140141func requiresBlobScope(action string) bool {142 switch action {143 case "upload", "delete", "mirror", "media":144 return true145 default:146 return false147 }148}149150func blossomServerScope(e nostr.Event, server string) bool {151 for _, value := range nostr.TagValues(e, "server") {152 if strings.EqualFold(strings.TrimSpace(value), strings.TrimSpace(server)) {153 return true154 }155 }156 return len(nostr.TagValues(e, "server")) == 0157}158159func (v *Validator) WhoAsks(header, rawURL, method, body, action, blobHash string) ([]string, error) {160 if strings.TrimSpace(header) == "" {161 return nil, nil162 }163 e, err := decodeToken(header, "authorization")164 if err != nil {165 return nil, err166 }167 if e.Kind == 24242 {168 if action == "" {169 return nil, authError("this door takes a NIP-98 signature")170 }171 e, err = v.VerifyBlossom(header, action)172 if err != nil {173 return nil, err174 }175 xs := nostr.TagValues(e, "x")176 if blobHash != "" && len(xs) > 0 && !contains(xs, blobHash) {177 return nil, authError("token x tag does not name this blob")178 }179 return []string{e.PubKey}, nil180 }181 e, err = v.VerifyNIP98(header, rawURL, method, body)182 if err != nil {183 return nil, err184 }185 return []string{e.PubKey}, nil186}187188// ValidateBlobPayload checks the hash binding on a token that was already189// authenticated by WhoAsks. It deliberately does not mark the event as seen a190// second time: upload authorization and the streamed body validator inspect191// the same proof at two different points in the request.192func (v *Validator) ValidateBlobPayload(header, actualSHA string) error {193 e, err := decodeToken(header, "authorization")194 if err != nil {195 return err196 }197 if err := nostr.Validate(e); err != nil {198 return authError(err.Error())199 }200 switch e.Kind {201 case 24242:202 if values := nostr.TagValues(e, "x"); len(values) > 0 && !contains(values, actualSHA) {203 return authError("token x tag does not name this blob")204 }205 case 27235:206 if nostr.Tag(e, "payload") != actualSHA {207 return authError("token payload hash does not match the blob hash")208 }209 default:210 return authError("token must be kind 24242 or 27235")211 }212 return nil213}214215// ValidateBlossomPayload rechecks the content hash for a Blossom-authenticated216// operation after a streaming body has been materialized. It does not mark the217// authorization event as seen a second time.218func (v *Validator) ValidateBlossomPayload(header, action, actualSHA string) error {219 e, err := v.VerifyBlossom(header, action)220 if err != nil {221 return err222 }223 if values := nostr.TagValues(e, "x"); len(values) > 0 && !contains(values, actualSHA) {224 return authError("token x tag does not name this blob")225 }226 return nil227}228229type ChallengeManager struct {230 relayURL string231 now func() time.Time232 mu sync.Mutex233 active map[string]time.Time234}235236// NewChallengeManager creates a NIP-42 challenge store for relayURL. Issued237// challenges are single-use and expire with the validator replay window.238func NewChallengeManager(relayURL string, now func() time.Time) *ChallengeManager {239 if now == nil {240 now = time.Now241 }242 return &ChallengeManager{relayURL: relayURL, now: now, active: make(map[string]time.Time)}243}244245// Issue creates and stores a cryptographically random NIP-42 challenge.246func (m *ChallengeManager) Issue() (string, error) {247 raw := make([]byte, 32)248 if _, err := rand.Read(raw); err != nil {249 return "", fmt.Errorf("create NIP-42 challenge: %w", err)250 }251 challenge := hex.EncodeToString(raw)252 now := m.now()253 m.mu.Lock()254 m.prune(now)255 m.active[challenge] = now.Add(tokenSkew)256 m.mu.Unlock()257 return challenge, nil258}259260// Verify validates and consumes a NIP-42 AUTH event for the configured relay.261func (m *ChallengeManager) Verify(e nostr.Event) error {262 if err := nostr.Validate(e); err != nil {263 return authError(err.Error())264 }265 if e.Kind != 22242 {266 return authError("AUTH event must be kind 22242")267 }268 now := m.now()269 if outsideSkew(now, e.CreatedAt) {270 return authError("AUTH event timestamp expired")271 }272 if nostr.Tag(e, "relay") != m.relayURL {273 return authError("AUTH event names another relay")274 }275 challenge := nostr.Tag(e, "challenge")276 m.mu.Lock()277 defer m.mu.Unlock()278 m.prune(now)279 if _, ok := m.active[challenge]; !ok {280 return authError("AUTH challenge is unknown or already used")281 }282 delete(m.active, challenge)283 return nil284}285286func (m *ChallengeManager) prune(now time.Time) {287 for challenge, expires := range m.active {288 if !expires.After(now) {289 delete(m.active, challenge)290 }291 }292}293294func (v *Validator) mark(id string, now time.Time) error {295 v.mu.Lock()296 defer v.mu.Unlock()297 for seenID, at := range v.seen {298 if now.Sub(at) > tokenSkew {299 delete(v.seen, seenID)300 }301 }302 if _, ok := v.seen[id]; ok {303 return authError("token replay detected")304 }305 v.seen[id] = now306 return nil307}308309func decodeToken(header, tokenName string) (nostr.Event, error) {310 parts := strings.Fields(header)311 if len(parts) != 2 || !strings.EqualFold(parts[0], "Nostr") {312 return nostr.Event{}, ErrMissingAuthorization313 }314 raw, err := decodeBase64(parts[1])315 if err != nil {316 return nostr.Event{}, authError(fmt.Sprintf("malformed %s token", tokenName))317 }318 e, err := nostr.Parse(raw)319 if err != nil {320 return nostr.Event{}, authError(err.Error())321 }322 return e, nil323}324325func decodeBase64(value string) ([]byte, error) {326 for _, encoding := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} {327 if raw, err := encoding.DecodeString(value); err == nil {328 return raw, nil329 }330 }331 return nil, errors.New("invalid base64")332}333334func sameRequestURL(signed, requested string) error {335 want, ok := normalizedRequestURL(requested)336 if !ok {337 return authError("token was signed for another URL")338 }339 got, ok := normalizedRequestURL(signed)340 if !ok || got != want {341 return authError("token was signed for another URL")342 }343 return nil344}345346func normalizedRequestURL(raw string) (string, bool) {347 u, err := url.Parse(strings.TrimSpace(raw))348 if err != nil || u.Scheme == "" || u.Host == "" || u.User != nil || u.Fragment != "" || u.Opaque != "" {349 return "", false350 }351 path := u.EscapedPath()352 if path == "" {353 path = "/"354 }355 // URL.Host preserves a possibly meaningful port while hostname matching is356 // case insensitive. Query strings remain part of the NIP-98 binding.357 host := strings.ToLower(u.Hostname())358 if port := u.Port(); port != "" {359 host += ":" + port360 }361 return strings.ToLower(u.Scheme) + "://" + host + path + "?" + u.RawQuery, true362}363364func outsideSkew(now time.Time, createdAt int64) bool {365 delta := now.Unix() - createdAt366 return delta > int64(tokenSkew/time.Second) || delta < -int64(tokenSkew/time.Second)367}368369func hashBytes(value []byte) string {370 sum := sha256.Sum256(value)371 return hex.EncodeToString(sum[:])372}373374func authError(reason string) error { return fmt.Errorf("auth-required: %s", reason) }375func contains(values []string, value string) bool {376 for _, candidate := range values {377 if candidate == value {378 return true379 }380 }381 return false382}