From 756c325223ef744b476ade565cb1970c7717d053 Mon Sep 17 00:00:00 2001 From: bndw Date: Sat, 14 Feb 2026 08:39:37 -0800 Subject: feat: implement NIP-98 HTTP auth for gRPC Add comprehensive NIP-98 authentication support following the standard gRPC credentials.PerRPCCredentials pattern. Client-side: - NostrCredentials implements PerRPCCredentials interface - Automatically signs each request with kind 27235 event - Drop-in replacement for OAuth2/JWT in gRPC clients Server-side: - Unary and stream interceptors for validation - Extracts and validates NIP-98 events from Authorization headers - Configurable options (timestamp window, whitelists, skip methods) - Adds authenticated pubkey to request context Security features: - Replay protection via timestamp validation - Optional payload hash verification - Signature verification using schnorr - TLS requirement option Includes comprehensive test coverage and detailed README with usage examples and security considerations. --- internal/auth/validation.go | 133 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 internal/auth/validation.go (limited to 'internal/auth/validation.go') diff --git a/internal/auth/validation.go b/internal/auth/validation.go new file mode 100644 index 0000000..11435ee --- /dev/null +++ b/internal/auth/validation.go @@ -0,0 +1,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 " +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 +} -- cgit v1.2.3