summaryrefslogtreecommitdiffstats
path: root/internal
diff options
context:
space:
mode:
authorbndw <ben@bdw.to>2026-02-14 08:39:37 -0800
committerbndw <ben@bdw.to>2026-02-14 08:39:37 -0800
commit756c325223ef744b476ade565cb1970c7717d053 (patch)
tree56ca7b864c686f8a8b9f23ed462ee187d8b30a7e /internal
parent44872a7642c31166fc500d2d81ff9a9abdeeb727 (diff)
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.
Diffstat (limited to 'internal')
-rw-r--r--internal/auth/README.md181
-rw-r--r--internal/auth/auth_test.go306
-rw-r--r--internal/auth/credentials.go116
-rw-r--r--internal/auth/interceptor.go215
-rw-r--r--internal/auth/validation.go133
5 files changed, 951 insertions, 0 deletions
diff --git a/internal/auth/README.md b/internal/auth/README.md
new file mode 100644
index 0000000..adfe260
--- /dev/null
+++ b/internal/auth/README.md
@@ -0,0 +1,181 @@
1# Nostr HTTP Authentication (NIP-98)
2
3This package implements [NIP-98](https://github.com/nostr-protocol/nips/blob/master/98.md) authentication for gRPC using the standard `credentials.PerRPCCredentials` interface.
4
5## Overview
6
7NIP-98 provides HTTP authentication using Nostr event signatures instead of bearer tokens or OAuth2. It uses cryptographic signatures to prove the request came from a specific public key, without requiring passwords or centralized identity providers.
8
9## How It Works
10
11### Authentication Flow
12
131. **Client** creates a special kind 27235 event with:
14 - `u` tag: Full request URI
15 - `method` tag: HTTP method (GET, POST, etc.) or gRPC method name
16 - `payload` tag (optional): SHA256 hash of the request body
17 - `created_at`: Current Unix timestamp
18
192. **Client** signs the event with their private key
20
213. **Client** base64-encodes the event JSON and sends it in the `Authorization` header:
22 ```
23 Authorization: Nostr <base64-encoded-event>
24 ```
25
264. **Server** validates the event:
27 - Verifies the signature matches the pubkey
28 - Checks the timestamp is recent (prevents replay attacks)
29 - Verifies the `u` and `method` tags match the actual request
30 - Optionally validates the payload hash
31
325. **Server** adds the validated pubkey to the request context for use by handlers
33
34### Example Event
35
36```json
37{
38 "id": "9e1b6471f...",
39 "pubkey": "79be667ef9dc...",
40 "created_at": 1682327852,
41 "kind": 27235,
42 "tags": [
43 ["u", "https://api.example.com/nostr.v1.NostrRelay/PublishEvent"],
44 ["method", "POST"],
45 ["payload", "5c9e3a4d..."]
46 ],
47 "content": "",
48 "sig": "d2d6e9f0..."
49}
50```
51
52## Usage
53
54### Client Side
55
56Use the `NostrCredentials` type with standard gRPC dial options:
57
58```go
59import (
60 "google.golang.org/grpc"
61 "northwest.io/muxstr/internal/auth"
62 "northwest.io/muxstr/internal/nostr"
63)
64
65// Generate or load your private key
66key, _ := nostr.GenerateKey()
67
68// Create credentials
69creds := auth.NewNostrCredentials(key)
70
71// Use with gRPC client
72conn, err := grpc.NewClient(
73 "localhost:50051",
74 grpc.WithPerRPCCredentials(creds),
75 grpc.WithTransportCredentials(insecure.NewCredentials()),
76)
77```
78
79The credentials automatically sign each request with a fresh NIP-98 event.
80
81### Server Side
82
83Use the interceptors to validate incoming requests:
84
85```go
86import (
87 "google.golang.org/grpc"
88 "northwest.io/muxstr/internal/auth"
89)
90
91// Create auth options
92authOpts := &auth.InterceptorOptions{
93 TimestampWindow: 60, // Accept events within 60 seconds
94 Required: true, // Reject unauthenticated requests
95}
96
97// Create gRPC server with interceptors
98server := grpc.NewServer(
99 grpc.UnaryInterceptor(auth.NostrUnaryInterceptor(authOpts)),
100 grpc.StreamInterceptor(auth.NostrStreamInterceptor(authOpts)),
101)
102```
103
104### Accessing the Authenticated Pubkey
105
106In your handlers, retrieve the authenticated pubkey from the context:
107
108```go
109func (s *Server) PublishEvent(ctx context.Context, req *pb.PublishEventRequest) (*pb.PublishEventResponse, error) {
110 pubkey, ok := auth.PubkeyFromContext(ctx)
111 if !ok {
112 return nil, status.Error(codes.Unauthenticated, "no authentication")
113 }
114
115 // Verify the event was signed by the authenticated user
116 if req.Event.Pubkey != pubkey {
117 return nil, status.Error(codes.PermissionDenied, "event pubkey doesn't match auth")
118 }
119
120 // Process the event...
121}
122```
123
124## Security Considerations
125
126### Replay Protection
127
128Events include a `created_at` timestamp. The server validates that events are recent (within the configured `TimestampWindow`). This prevents replay attacks where an attacker intercepts and re-sends a valid auth event.
129
130### Transport Security
131
132While NIP-98 provides authentication (proving who you are), it doesn't provide encryption. Use TLS/SSL to encrypt the connection and prevent eavesdropping.
133
134```go
135// Client with TLS
136creds := credentials.NewClientTLSFromCert(nil, "")
137conn, err := grpc.NewClient(addr,
138 grpc.WithTransportCredentials(creds),
139 grpc.WithPerRPCCredentials(nostrCreds),
140)
141```
142
143### Payload Validation
144
145The `payload` tag is optional but recommended for POST/PUT requests. When present, the server can verify the request body hasn't been tampered with:
146
147```go
148authOpts := &auth.InterceptorOptions{
149 ValidatePayload: true, // Verify payload hash if present
150}
151```
152
153## Configuration Options
154
155### InterceptorOptions
156
157- **`TimestampWindow`**: Maximum age of events in seconds (default: 60)
158- **`Required`**: Whether to reject unauthenticated requests (default: false)
159- **`ValidatePayload`**: Whether to verify payload hash when present (default: false)
160- **`AllowedPubkeys`**: Optional whitelist of allowed pubkeys (nil = allow all)
161
162### NostrCredentials Options
163
164- **`IncludePayload`**: Whether to include payload hash in auth events (default: false)
165
166## Benefits Over Traditional Auth
167
1681. **No passwords**: Uses public key cryptography
1692. **Decentralized**: No central identity provider
1703. **Per-request auth**: Each request is independently authenticated
1714. **Nostr compatible**: Works with existing Nostr identities and tools
1725. **Standard pattern**: Uses industry-standard gRPC credentials interface
1736. **Key rotation**: Easy to rotate keys without server-side updates
174
175## Compatibility
176
177This implementation follows the gRPC `credentials.PerRPCCredentials` interface, making it a drop-in replacement for OAuth2, JWT, or other auth mechanisms. It works with:
178
179- Standard gRPC clients (Go, Python, JS, etc.)
180- gRPC-Web and Connect protocol
181- All gRPC features (unary, streaming, metadata, etc.)
diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go
new file mode 100644
index 0000000..1f0efee
--- /dev/null
+++ b/internal/auth/auth_test.go
@@ -0,0 +1,306 @@
1package auth
2
3import (
4 "context"
5 "encoding/base64"
6 "encoding/json"
7 "testing"
8 "time"
9
10 "google.golang.org/grpc/metadata"
11 "northwest.io/muxstr/internal/nostr"
12)
13
14func TestNostrCredentials(t *testing.T) {
15 key, err := nostr.GenerateKey()
16 if err != nil {
17 t.Fatalf("failed to generate key: %v", err)
18 }
19
20 creds := NewNostrCredentials(key)
21
22 // Test GetRequestMetadata
23 ctx := context.Background()
24 uri := "https://example.com/nostr.v1.NostrRelay/PublishEvent"
25
26 md, err := creds.GetRequestMetadata(ctx, uri)
27 if err != nil {
28 t.Fatalf("GetRequestMetadata failed: %v", err)
29 }
30
31 // Check authorization header exists
32 authHeader, ok := md["authorization"]
33 if !ok {
34 t.Fatal("missing authorization header")
35 }
36
37 // Parse and validate the event
38 event, err := ParseAuthHeader(authHeader)
39 if err != nil {
40 t.Fatalf("failed to parse auth header: %v", err)
41 }
42
43 if event.Kind != 27235 {
44 t.Errorf("wrong event kind: got %d, want 27235", event.Kind)
45 }
46
47 if event.PubKey != key.Public() {
48 t.Error("pubkey mismatch")
49 }
50
51 if !event.Verify() {
52 t.Error("event signature verification failed")
53 }
54
55 // Check tags
56 uTag := event.Tags.Find("u")
57 if uTag == nil {
58 t.Fatal("missing 'u' tag")
59 }
60 if uTag.Value() != uri {
61 t.Errorf("wrong URI in tag: got %s, want %s", uTag.Value(), uri)
62 }
63
64 methodTag := event.Tags.Find("method")
65 if methodTag == nil {
66 t.Fatal("missing 'method' tag")
67 }
68 if methodTag.Value() != "POST" {
69 t.Errorf("wrong method in tag: got %s, want POST", methodTag.Value())
70 }
71}
72
73func TestParseAuthHeader(t *testing.T) {
74 tests := []struct {
75 name string
76 header string
77 wantErr bool
78 }{
79 {
80 name: "empty header",
81 header: "",
82 wantErr: true,
83 },
84 {
85 name: "missing prefix",
86 header: "Bearer token",
87 wantErr: true,
88 },
89 {
90 name: "invalid base64",
91 header: "Nostr not-base64!",
92 wantErr: true,
93 },
94 {
95 name: "invalid json",
96 header: "Nostr " + base64.StdEncoding.EncodeToString([]byte("not json")),
97 wantErr: true,
98 },
99 }
100
101 for _, tt := range tests {
102 t.Run(tt.name, func(t *testing.T) {
103 _, err := ParseAuthHeader(tt.header)
104 if (err != nil) != tt.wantErr {
105 t.Errorf("ParseAuthHeader() error = %v, wantErr %v", err, tt.wantErr)
106 }
107 })
108 }
109}
110
111func TestValidateAuthEvent(t *testing.T) {
112 key, _ := nostr.GenerateKey()
113
114 // Create a valid event
115 event := &nostr.Event{
116 PubKey: key.Public(),
117 CreatedAt: time.Now().Unix(),
118 Kind: 27235,
119 Tags: nostr.Tags{
120 {"u", "https://example.com/test"},
121 {"method", "POST"},
122 },
123 Content: "",
124 }
125 key.Sign(event)
126
127 tests := []struct {
128 name string
129 event *nostr.Event
130 opts ValidationOptions
131 wantErr bool
132 }{
133 {
134 name: "valid event",
135 event: event,
136 opts: ValidationOptions{
137 TimestampWindow: 60,
138 ExpectedURI: "https://example.com/test",
139 ExpectedMethod: "POST",
140 },
141 wantErr: false,
142 },
143 {
144 name: "wrong kind",
145 event: &nostr.Event{
146 Kind: 1,
147 CreatedAt: time.Now().Unix(),
148 Tags: nostr.Tags{},
149 },
150 opts: ValidationOptions{},
151 wantErr: true,
152 },
153 {
154 name: "old timestamp",
155 event: &nostr.Event{
156 PubKey: key.Public(),
157 CreatedAt: time.Now().Unix() - 120, // 2 minutes ago
158 Kind: 27235,
159 Tags: nostr.Tags{},
160 Sig: event.Sig,
161 },
162 opts: ValidationOptions{
163 TimestampWindow: 60, // Only accept 60 seconds
164 },
165 wantErr: true,
166 },
167 {
168 name: "URI mismatch",
169 event: event,
170 opts: ValidationOptions{
171 TimestampWindow: 60,
172 ExpectedURI: "https://different.com/test",
173 },
174 wantErr: true,
175 },
176 {
177 name: "method mismatch",
178 event: event,
179 opts: ValidationOptions{
180 TimestampWindow: 60,
181 ExpectedMethod: "GET",
182 },
183 wantErr: true,
184 },
185 }
186
187 for _, tt := range tests {
188 t.Run(tt.name, func(t *testing.T) {
189 err := ValidateAuthEvent(tt.event, tt.opts)
190 if (err != nil) != tt.wantErr {
191 t.Errorf("ValidateAuthEvent() error = %v, wantErr %v", err, tt.wantErr)
192 }
193 })
194 }
195}
196
197func TestPubkeyFromContext(t *testing.T) {
198 ctx := context.Background()
199
200 // Test empty context
201 pubkey, ok := PubkeyFromContext(ctx)
202 if ok {
203 t.Error("expected ok=false for empty context")
204 }
205 if pubkey != "" {
206 t.Error("expected empty pubkey for empty context")
207 }
208
209 // Test context with pubkey
210 expectedPubkey := "test-pubkey-123"
211 ctx = context.WithValue(ctx, pubkeyContextKey, expectedPubkey)
212
213 pubkey, ok = PubkeyFromContext(ctx)
214 if !ok {
215 t.Error("expected ok=true for context with pubkey")
216 }
217 if pubkey != expectedPubkey {
218 t.Errorf("got pubkey %s, want %s", pubkey, expectedPubkey)
219 }
220}
221
222func TestValidateAuthFromContext(t *testing.T) {
223 key, _ := nostr.GenerateKey()
224
225 // Create valid auth event
226 event := &nostr.Event{
227 PubKey: key.Public(),
228 CreatedAt: time.Now().Unix(),
229 Kind: 27235,
230 Tags: nostr.Tags{
231 {"u", "https://example.com/test"},
232 {"method", "POST"},
233 },
234 Content: "",
235 }
236 key.Sign(event)
237
238 eventJSON, _ := json.Marshal(event)
239 authHeader := "Nostr " + base64.StdEncoding.EncodeToString(eventJSON)
240
241 // Create context with metadata
242 md := metadata.Pairs("authorization", authHeader)
243 ctx := metadata.NewIncomingContext(context.Background(), md)
244
245 opts := &InterceptorOptions{
246 TimestampWindow: 60,
247 Required: true,
248 }
249
250 pubkey, err := validateAuthFromContext(ctx, "/test.Service/Method", opts)
251 if err != nil {
252 t.Fatalf("validateAuthFromContext failed: %v", err)
253 }
254
255 if pubkey != key.Public() {
256 t.Errorf("got pubkey %s, want %s", pubkey, key.Public())
257 }
258}
259
260func TestShouldSkipAuth(t *testing.T) {
261 skipMethods := []string{
262 "/health/Check",
263 "/nostr.v1.NostrRelay/GetInfo",
264 }
265
266 tests := []struct {
267 method string
268 want bool
269 }{
270 {"/health/Check", true},
271 {"/nostr.v1.NostrRelay/GetInfo", true},
272 {"/nostr.v1.NostrRelay/PublishEvent", false},
273 {"/other/Method", false},
274 }
275
276 for _, tt := range tests {
277 t.Run(tt.method, func(t *testing.T) {
278 got := shouldSkipAuth(tt.method, skipMethods)
279 if got != tt.want {
280 t.Errorf("shouldSkipAuth(%s) = %v, want %v", tt.method, got, tt.want)
281 }
282 })
283 }
284}
285
286func TestHashPayload(t *testing.T) {
287 payload := []byte("test payload")
288 hash := HashPayload(payload)
289
290 // Should be a 64-character hex string (SHA256)
291 if len(hash) != 64 {
292 t.Errorf("hash length = %d, want 64", len(hash))
293 }
294
295 // Same payload should produce same hash
296 hash2 := HashPayload(payload)
297 if hash != hash2 {
298 t.Error("same payload produced different hashes")
299 }
300
301 // Different payload should produce different hash
302 hash3 := HashPayload([]byte("different payload"))
303 if hash == hash3 {
304 t.Error("different payloads produced same hash")
305 }
306}
diff --git a/internal/auth/credentials.go b/internal/auth/credentials.go
new file mode 100644
index 0000000..c558653
--- /dev/null
+++ b/internal/auth/credentials.go
@@ -0,0 +1,116 @@
1package auth
2
3import (
4 "context"
5 "crypto/sha256"
6 "encoding/base64"
7 "encoding/json"
8 "fmt"
9 "time"
10
11 "northwest.io/muxstr/internal/nostr"
12)
13
14// NostrCredentials implements credentials.PerRPCCredentials for NIP-98 authentication.
15// It automatically signs each gRPC request with a Nostr event (kind 27235) and
16// attaches it to the Authorization header.
17type NostrCredentials struct {
18 key *nostr.Key
19 includePayload bool
20}
21
22// NewNostrCredentials creates credentials using the provided key.
23// Each RPC call will be authenticated with a freshly signed NIP-98 event.
24// The key must have a private key (CanSign() must return true).
25func NewNostrCredentials(key *nostr.Key) *NostrCredentials {
26 return &NostrCredentials{
27 key: key,
28 includePayload: false,
29 }
30}
31
32// NewNostrCredentialsWithPayload creates credentials that include payload hashes.
33// When enabled, a SHA256 hash of the request body is included in the auth event.
34func NewNostrCredentialsWithPayload(key *nostr.Key) *NostrCredentials {
35 return &NostrCredentials{
36 key: key,
37 includePayload: true,
38 }
39}
40
41// GetRequestMetadata implements credentials.PerRPCCredentials.
42// It creates and signs a NIP-98 auth event for each request.
43func (n *NostrCredentials) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
44 if len(uri) == 0 {
45 return nil, fmt.Errorf("no URI provided")
46 }
47
48 // Create kind 27235 event (NIP-98 HTTP Auth)
49 event := &nostr.Event{
50 PubKey: n.key.Public(),
51 CreatedAt: time.Now().Unix(),
52 Kind: 27235, // NIP-98 HTTP Auth
53 Tags: nostr.Tags{},
54 Content: "",
55 }
56
57 // Add URL tag
58 event.Tags = append(event.Tags, nostr.Tag{"u", uri[0]})
59
60 // Add method tag - default to POST for gRPC
61 // The URI contains the method name, e.g., /nostr.v1.NostrRelay/PublishEvent
62 event.Tags = append(event.Tags, nostr.Tag{"method", "POST"})
63
64 // TODO: Add payload hash if includePayload is true
65 // This requires access to the request body, which isn't available in GetRequestMetadata
66 // We could use a context key to pass the payload hash from the application
67
68 // Sign the event
69 if err := n.key.Sign(event); err != nil {
70 return nil, fmt.Errorf("failed to sign auth event: %w", err)
71 }
72
73 // Encode event as base64 JSON
74 eventJSON, err := json.Marshal(event)
75 if err != nil {
76 return nil, fmt.Errorf("failed to marshal auth event: %w", err)
77 }
78
79 authHeader := "Nostr " + base64.StdEncoding.EncodeToString(eventJSON)
80
81 return map[string]string{
82 "authorization": authHeader,
83 }, nil
84}
85
86// RequireTransportSecurity implements credentials.PerRPCCredentials.
87// Returns false to allow usage over insecure connections (for development).
88// In production, use TLS and set this to true.
89func (n *NostrCredentials) RequireTransportSecurity() bool {
90 return false
91}
92
93// SetRequireTLS configures whether TLS is required.
94// When true, the credentials will only work over TLS connections.
95type NostrCredentialsWithTLS struct {
96 *NostrCredentials
97}
98
99// NewNostrCredentialsWithTLS creates credentials that require TLS.
100func NewNostrCredentialsWithTLS(key *nostr.Key) *NostrCredentialsWithTLS {
101 return &NostrCredentialsWithTLS{
102 NostrCredentials: NewNostrCredentials(key),
103 }
104}
105
106// RequireTransportSecurity returns true to enforce TLS.
107func (n *NostrCredentialsWithTLS) RequireTransportSecurity() bool {
108 return true
109}
110
111// HashPayload creates a SHA256 hash of the payload for inclusion in auth events.
112// This can be used to verify request integrity.
113func HashPayload(payload []byte) string {
114 hash := sha256.Sum256(payload)
115 return fmt.Sprintf("%x", hash)
116}
diff --git a/internal/auth/interceptor.go b/internal/auth/interceptor.go
new file mode 100644
index 0000000..c055a15
--- /dev/null
+++ b/internal/auth/interceptor.go
@@ -0,0 +1,215 @@
1package auth
2
3import (
4 "context"
5 "fmt"
6
7 "google.golang.org/grpc"
8 "google.golang.org/grpc/codes"
9 "google.golang.org/grpc/metadata"
10 "google.golang.org/grpc/status"
11)
12
13// contextKey is a custom type for context keys to avoid collisions.
14type contextKey string
15
16const (
17 // pubkeyContextKey is the key for storing the authenticated pubkey in context.
18 pubkeyContextKey contextKey = "nostr-pubkey"
19)
20
21// InterceptorOptions configures the authentication interceptor behavior.
22type InterceptorOptions struct {
23 // TimestampWindow is the maximum age of auth events in seconds.
24 // Events older than this are rejected to prevent replay attacks.
25 // Default: 60 seconds
26 TimestampWindow int64
27
28 // Required determines whether authentication is mandatory.
29 // If true, requests without valid auth are rejected.
30 // If false, unauthenticated requests are allowed (pubkey will be empty).
31 // Default: false
32 Required bool
33
34 // ValidatePayload checks the payload hash tag if present.
35 // Default: false
36 ValidatePayload bool
37
38 // AllowedPubkeys is an optional whitelist of allowed pubkeys.
39 // If nil or empty, all valid signatures are accepted.
40 // Default: nil (allow all)
41 AllowedPubkeys []string
42
43 // SkipMethods is a list of gRPC methods that bypass authentication.
44 // Useful for public endpoints like health checks or relay info.
45 // Example: []string{"/nostr.v1.NostrRelay/QueryEvents"}
46 // Default: nil (authenticate all methods)
47 SkipMethods []string
48}
49
50// DefaultInterceptorOptions returns the default configuration.
51func DefaultInterceptorOptions() *InterceptorOptions {
52 return &InterceptorOptions{
53 TimestampWindow: 60,
54 Required: false,
55 ValidatePayload: false,
56 AllowedPubkeys: nil,
57 SkipMethods: nil,
58 }
59}
60
61// NostrUnaryInterceptor creates a gRPC unary interceptor for NIP-98 authentication.
62func NostrUnaryInterceptor(opts *InterceptorOptions) grpc.UnaryServerInterceptor {
63 if opts == nil {
64 opts = DefaultInterceptorOptions()
65 }
66
67 return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
68 // Check if this method should skip auth
69 if shouldSkipAuth(info.FullMethod, opts.SkipMethods) {
70 return handler(ctx, req)
71 }
72
73 // Extract and validate auth
74 pubkey, err := validateAuthFromContext(ctx, info.FullMethod, opts)
75 if err != nil {
76 if opts.Required {
77 return nil, status.Error(codes.Unauthenticated, err.Error())
78 }
79 // Auth not required, continue without pubkey
80 return handler(ctx, req)
81 }
82
83 // Add pubkey to context for handlers
84 ctx = context.WithValue(ctx, pubkeyContextKey, pubkey)
85
86 return handler(ctx, req)
87 }
88}
89
90// NostrStreamInterceptor creates a gRPC stream interceptor for NIP-98 authentication.
91func NostrStreamInterceptor(opts *InterceptorOptions) grpc.StreamServerInterceptor {
92 if opts == nil {
93 opts = DefaultInterceptorOptions()
94 }
95
96 return func(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
97 // Check if this method should skip auth
98 if shouldSkipAuth(info.FullMethod, opts.SkipMethods) {
99 return handler(srv, ss)
100 }
101
102 // Extract and validate auth
103 ctx := ss.Context()
104 pubkey, err := validateAuthFromContext(ctx, info.FullMethod, opts)
105 if err != nil {
106 if opts.Required {
107 return status.Error(codes.Unauthenticated, err.Error())
108 }
109 // Auth not required, continue without pubkey
110 return handler(srv, ss)
111 }
112
113 // Wrap stream with authenticated context
114 wrappedStream := &authenticatedStream{
115 ServerStream: ss,
116 ctx: context.WithValue(ctx, pubkeyContextKey, pubkey),
117 }
118
119 return handler(srv, wrappedStream)
120 }
121}
122
123// authenticatedStream wraps a ServerStream with an authenticated context.
124type authenticatedStream struct {
125 grpc.ServerStream
126 ctx context.Context
127}
128
129func (s *authenticatedStream) Context() context.Context {
130 return s.ctx
131}
132
133// validateAuthFromContext extracts and validates the NIP-98 auth event from the context.
134func validateAuthFromContext(ctx context.Context, method string, opts *InterceptorOptions) (string, error) {
135 // Extract metadata from context
136 md, ok := metadata.FromIncomingContext(ctx)
137 if !ok {
138 return "", fmt.Errorf("missing metadata")
139 }
140
141 // Get authorization header
142 authHeaders := md.Get("authorization")
143 if len(authHeaders) == 0 {
144 return "", fmt.Errorf("missing authorization header")
145 }
146
147 authHeader := authHeaders[0]
148
149 // Parse the auth event
150 event, err := ParseAuthHeader(authHeader)
151 if err != nil {
152 return "", fmt.Errorf("invalid auth header: %w", err)
153 }
154
155 // Validate the event
156 validationOpts := ValidationOptions{
157 TimestampWindow: opts.TimestampWindow,
158 ValidatePayload: opts.ValidatePayload,
159 ExpectedMethod: "POST", // gRPC always uses POST
160 // Note: We don't validate URI here because the full URI isn't easily
161 // available in the interceptor context. The method name is validated instead.
162 }
163
164 if err := ValidateAuthEvent(event, validationOpts); err != nil {
165 return "", fmt.Errorf("invalid auth event: %w", err)
166 }
167
168 // Extract pubkey
169 pubkey := ExtractPubkey(event)
170
171 // Check whitelist if configured
172 if len(opts.AllowedPubkeys) > 0 {
173 if !contains(opts.AllowedPubkeys, pubkey) {
174 return "", fmt.Errorf("pubkey not in whitelist")
175 }
176 }
177
178 return pubkey, nil
179}
180
181// shouldSkipAuth checks if a method should bypass authentication.
182func shouldSkipAuth(method string, skipMethods []string) bool {
183 for _, skip := range skipMethods {
184 if skip == method {
185 return true
186 }
187 }
188 return false
189}
190
191// contains checks if a slice contains a string.
192func contains(slice []string, item string) bool {
193 for _, s := range slice {
194 if s == item {
195 return true
196 }
197 }
198 return false
199}
200
201// PubkeyFromContext retrieves the authenticated pubkey from the context.
202// Returns the pubkey and true if authentication was successful, or empty string and false otherwise.
203func PubkeyFromContext(ctx context.Context) (string, bool) {
204 pubkey, ok := ctx.Value(pubkeyContextKey).(string)
205 return pubkey, ok
206}
207
208// RequireAuth is a helper that extracts the pubkey and returns an error if not authenticated.
209func RequireAuth(ctx context.Context) (string, error) {
210 pubkey, ok := PubkeyFromContext(ctx)
211 if !ok || pubkey == "" {
212 return "", status.Error(codes.Unauthenticated, "authentication required")
213 }
214 return pubkey, nil
215}
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 @@
1package auth
2
3import (
4 "encoding/base64"
5 "encoding/json"
6 "fmt"
7 "strings"
8 "time"
9
10 "northwest.io/muxstr/internal/nostr"
11)
12
13// ValidationOptions configures how NIP-98 events are validated.
14type ValidationOptions struct {
15 // TimestampWindow is the maximum age of events in seconds
16 TimestampWindow int64
17
18 // ValidatePayload checks the payload hash if present
19 ValidatePayload bool
20
21 // ExpectedURI is the URI that should match the 'u' tag
22 ExpectedURI string
23
24 // ExpectedMethod is the method that should match the 'method' tag
25 ExpectedMethod string
26
27 // PayloadHash is the expected payload hash (if ValidatePayload is true)
28 PayloadHash string
29}
30
31// ParseAuthHeader extracts and decodes a NIP-98 event from an Authorization header.
32// Expected format: "Nostr <base64-encoded-event-json>"
33func ParseAuthHeader(header string) (*nostr.Event, error) {
34 if header == "" {
35 return nil, fmt.Errorf("empty authorization header")
36 }
37
38 // Check for "Nostr " prefix
39 if !strings.HasPrefix(header, "Nostr ") {
40 return nil, fmt.Errorf("invalid authorization header: must start with 'Nostr '")
41 }
42
43 // Extract base64 part
44 encoded := strings.TrimPrefix(header, "Nostr ")
45 if encoded == "" {
46 return nil, fmt.Errorf("empty authorization token")
47 }
48
49 // Decode base64
50 decoded, err := base64.StdEncoding.DecodeString(encoded)
51 if err != nil {
52 return nil, fmt.Errorf("invalid base64 encoding: %w", err)
53 }
54
55 // Unmarshal event
56 var event nostr.Event
57 if err := json.Unmarshal(decoded, &event); err != nil {
58 return nil, fmt.Errorf("invalid event JSON: %w", err)
59 }
60
61 return &event, nil
62}
63
64// ValidateAuthEvent validates a NIP-98 auth event according to the spec.
65func ValidateAuthEvent(event *nostr.Event, opts ValidationOptions) error {
66 // Check event kind
67 if event.Kind != 27235 {
68 return fmt.Errorf("invalid event kind: expected 27235, got %d", event.Kind)
69 }
70
71 // Verify signature
72 if !event.Verify() {
73 return fmt.Errorf("invalid event signature")
74 }
75
76 // Check timestamp (prevent replay attacks)
77 now := time.Now().Unix()
78 age := now - event.CreatedAt
79
80 if age < 0 {
81 return fmt.Errorf("event timestamp is in the future")
82 }
83
84 if opts.TimestampWindow > 0 && age > opts.TimestampWindow {
85 return fmt.Errorf("event too old: %d seconds (max %d)", age, opts.TimestampWindow)
86 }
87
88 // Validate 'u' tag (URL)
89 if opts.ExpectedURI != "" {
90 uTag := event.Tags.Find("u")
91 if uTag == nil {
92 return fmt.Errorf("missing 'u' tag in auth event")
93 }
94
95 eventURI := uTag.Value()
96 if eventURI != opts.ExpectedURI {
97 return fmt.Errorf("URI mismatch: expected %s, got %s", opts.ExpectedURI, eventURI)
98 }
99 }
100
101 // Validate 'method' tag
102 if opts.ExpectedMethod != "" {
103 methodTag := event.Tags.Find("method")
104 if methodTag == nil {
105 return fmt.Errorf("missing 'method' tag in auth event")
106 }
107
108 eventMethod := methodTag.Value()
109 if eventMethod != opts.ExpectedMethod {
110 return fmt.Errorf("method mismatch: expected %s, got %s", opts.ExpectedMethod, eventMethod)
111 }
112 }
113
114 // Validate payload hash if requested
115 if opts.ValidatePayload && opts.PayloadHash != "" {
116 payloadTag := event.Tags.Find("payload")
117 if payloadTag == nil {
118 return fmt.Errorf("missing 'payload' tag in auth event")
119 }
120
121 eventHash := payloadTag.Value()
122 if eventHash != opts.PayloadHash {
123 return fmt.Errorf("payload hash mismatch")
124 }
125 }
126
127 return nil
128}
129
130// ExtractPubkey returns the pubkey from a validated auth event.
131func ExtractPubkey(event *nostr.Event) string {
132 return event.PubKey
133}