summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorbndw <ben@bdw.to>2026-02-14 09:58:28 -0800
committerbndw <ben@bdw.to>2026-02-14 09:58:28 -0800
commitd30459513ec44ab298fafd1bfe0edc08d6ab62e4 (patch)
tree1e4442f940c11544cd60b6bf72f2038338da67ce
parentfe3708eaf495613cc6e2340b821795f25811d6ed (diff)
feat: rename allowed_pubkeys to allowed_npubs with normalization
- Config now accepts npub format only (human-readable) - Automatically converts npubs to hex pubkeys at load time - Updated InterceptorOptions.AllowedPubkeys -> AllowedNpubs - Added validation to reject hex format in config (npub only) - Updated documentation to clarify npub-only config - Added comprehensive tests for npub normalization Config is for humans (npub), internal code uses hex pubkeys.
-rw-r--r--.ship/Caddyfile18
-rw-r--r--.ship/service17
-rw-r--r--Makefile5
-rw-r--r--internal/auth/README.md4
-rw-r--r--internal/auth/interceptor.go13
-rw-r--r--internal/config/README.md12
-rw-r--r--internal/config/config.go47
-rw-r--r--internal/config/config_test.go97
8 files changed, 198 insertions, 15 deletions
diff --git a/.ship/Caddyfile b/.ship/Caddyfile
new file mode 100644
index 0000000..88ed6d3
--- /dev/null
+++ b/.ship/Caddyfile
@@ -0,0 +1,18 @@
1nostr-grpc.x.bdw.to {
2 # Route native gRPC to port 50051
3 @grpc {
4 header Content-Type application/grpc*
5 }
6 reverse_proxy @grpc localhost:50051 {
7 transport http {
8 versions h2c
9 }
10 }
11
12 # Everything else (Connect, WebSocket, HTML) to port 8006
13 reverse_proxy localhost:8006 {
14 # Enable WebSocket support
15 header_up Upgrade {http.request.header.Upgrade}
16 header_up Connection {http.request.header.Connection}
17 }
18}
diff --git a/.ship/service b/.ship/service
new file mode 100644
index 0000000..9305ef4
--- /dev/null
+++ b/.ship/service
@@ -0,0 +1,17 @@
1[Unit]
2Description=nostr-grpc
3After=network.target
4
5[Service]
6Type=simple
7User=nostr-grpc
8WorkingDirectory=/var/lib/nostr-grpc
9EnvironmentFile=/etc/ship/env/nostr-grpc.env
10ExecStart=/usr/local/bin/nostr-grpc -db relay.db -grpc-addr localhost:50051 -ws-addr localhost:8006 -public-url nostr-grpc.x.bdw.to
11Restart=always
12RestartSec=5s
13NoNewPrivileges=true
14PrivateTmp=true
15
16[Install]
17WantedBy=multi-user.target
diff --git a/Makefile b/Makefile
index 87ede58..35ea9f7 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
1.PHONY: proto proto-lint proto-breaking test build clean 1.PHONY: proto proto-lint proto-breaking test build clean deploy
2 2
3# Generate proto files 3# Generate proto files
4proto: 4proto:
@@ -32,6 +32,9 @@ clean:
32 rm -rf api/ 32 rm -rf api/
33 rm -f bin/relay 33 rm -f bin/relay
34 34
35deploy:
36 ship --name nostr-grpc --binary ./bin/relay
37
35# Install buf (if not already installed) 38# Install buf (if not already installed)
36install-buf: 39install-buf:
37 @if ! command -v buf &> /dev/null; then \ 40 @if ! command -v buf &> /dev/null; then \
diff --git a/internal/auth/README.md b/internal/auth/README.md
index c41b6cb..df0de6a 100644
--- a/internal/auth/README.md
+++ b/internal/auth/README.md
@@ -209,7 +209,9 @@ authOpts := &auth.InterceptorOptions{
209- **`TimestampWindow`**: Maximum age of events in seconds (default: 60) 209- **`TimestampWindow`**: Maximum age of events in seconds (default: 60)
210- **`Required`**: Whether to reject unauthenticated requests (default: false) 210- **`Required`**: Whether to reject unauthenticated requests (default: false)
211- **`ValidatePayload`**: Whether to verify payload hash when present (default: false) 211- **`ValidatePayload`**: Whether to verify payload hash when present (default: false)
212- **`AllowedPubkeys`**: Optional whitelist of allowed pubkeys (nil = allow all) 212- **`AllowedNpubs`**: Optional whitelist of allowed pubkeys (nil = allow all)
213 - Config accepts npub format only (human-readable bech32)
214 - Automatically normalized to hex format (computer-readable) at config load time
213 215
214### NostrCredentials Options 216### NostrCredentials Options
215 217
diff --git a/internal/auth/interceptor.go b/internal/auth/interceptor.go
index c055a15..7d785bf 100644
--- a/internal/auth/interceptor.go
+++ b/internal/auth/interceptor.go
@@ -35,10 +35,11 @@ type InterceptorOptions struct {
35 // Default: false 35 // Default: false
36 ValidatePayload bool 36 ValidatePayload bool
37 37
38 // AllowedPubkeys is an optional whitelist of allowed pubkeys. 38 // AllowedNpubs is an optional whitelist of allowed pubkeys (hex format).
39 // Config accepts npub format only, normalized to hex at load time.
39 // If nil or empty, all valid signatures are accepted. 40 // If nil or empty, all valid signatures are accepted.
40 // Default: nil (allow all) 41 // Default: nil (allow all)
41 AllowedPubkeys []string 42 AllowedNpubs []string
42 43
43 // SkipMethods is a list of gRPC methods that bypass authentication. 44 // SkipMethods is a list of gRPC methods that bypass authentication.
44 // Useful for public endpoints like health checks or relay info. 45 // Useful for public endpoints like health checks or relay info.
@@ -53,7 +54,7 @@ func DefaultInterceptorOptions() *InterceptorOptions {
53 TimestampWindow: 60, 54 TimestampWindow: 60,
54 Required: false, 55 Required: false,
55 ValidatePayload: false, 56 ValidatePayload: false,
56 AllowedPubkeys: nil, 57 AllowedNpubs: nil,
57 SkipMethods: nil, 58 SkipMethods: nil,
58 } 59 }
59} 60}
@@ -168,9 +169,9 @@ func validateAuthFromContext(ctx context.Context, method string, opts *Intercept
168 // Extract pubkey 169 // Extract pubkey
169 pubkey := ExtractPubkey(event) 170 pubkey := ExtractPubkey(event)
170 171
171 // Check whitelist if configured 172 // Check whitelist if configured (all values are already normalized to hex)
172 if len(opts.AllowedPubkeys) > 0 { 173 if len(opts.AllowedNpubs) > 0 {
173 if !contains(opts.AllowedPubkeys, pubkey) { 174 if !contains(opts.AllowedNpubs, pubkey) {
174 return "", fmt.Errorf("pubkey not in whitelist") 175 return "", fmt.Errorf("pubkey not in whitelist")
175 } 176 }
176 } 177 }
diff --git a/internal/config/README.md b/internal/config/README.md
index 79e1b89..dbb8760 100644
--- a/internal/config/README.md
+++ b/internal/config/README.md
@@ -90,9 +90,13 @@ auth:
90 # Timestamp window in seconds for replay protection 90 # Timestamp window in seconds for replay protection
91 timestamp_window: 60 91 timestamp_window: 60
92 92
93 # Allowed pubkeys (optional, whitelist) 93 # Allowed npubs (optional, whitelist)
94 # If empty, all valid signatures are accepted 94 # If empty, all valid signatures are accepted
95 allowed_pubkeys: [] 95 # Use npub format only (e.g., npub1...)
96 allowed_npubs: []
97 # Example:
98 # allowed_npubs:
99 # - npub1a2b3c4d5e6f...
96 100
97 # Skip authentication for these methods 101 # Skip authentication for these methods
98 skip_methods: 102 skip_methods:
@@ -217,8 +221,8 @@ Examples:
217Complex types: 221Complex types:
218 222
219```bash 223```bash
220# Lists (comma-separated) 224# Lists (comma-separated, npub format)
221export MUXSTR_AUTH_ALLOWED_PUBKEYS="pubkey1,pubkey2,pubkey3" 225export MUXSTR_AUTH_ALLOWED_NPUBS="npub1...,npub1...,npub1..."
222 226
223# Durations 227# Durations
224export MUXSTR_SERVER_READ_TIMEOUT="30s" 228export MUXSTR_SERVER_READ_TIMEOUT="30s"
diff --git a/internal/config/config.go b/internal/config/config.go
index 91e79f7..0566537 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -6,6 +6,7 @@ import (
6 "strings" 6 "strings"
7 "time" 7 "time"
8 8
9 "northwest.io/muxstr/internal/nostr"
9 "gopkg.in/yaml.v3" 10 "gopkg.in/yaml.v3"
10) 11)
11 12
@@ -41,7 +42,7 @@ type AuthConfig struct {
41 Enabled bool `yaml:"enabled"` 42 Enabled bool `yaml:"enabled"`
42 Required bool `yaml:"required"` 43 Required bool `yaml:"required"`
43 TimestampWindow int64 `yaml:"timestamp_window"` 44 TimestampWindow int64 `yaml:"timestamp_window"`
44 AllowedPubkeys []string `yaml:"allowed_pubkeys"` 45 AllowedNpubs []string `yaml:"allowed_npubs"` // npub format only (bech32) - normalized to hex internally
45 SkipMethods []string `yaml:"skip_methods"` 46 SkipMethods []string `yaml:"skip_methods"`
46} 47}
47 48
@@ -162,6 +163,11 @@ func Load(filename string) (*Config, error) {
162 // Apply environment variable overrides 163 // Apply environment variable overrides
163 applyEnvOverrides(cfg) 164 applyEnvOverrides(cfg)
164 165
166 // Normalize npubs to hex pubkeys
167 if err := normalizeNpubs(cfg); err != nil {
168 return nil, fmt.Errorf("failed to normalize npubs: %w", err)
169 }
170
165 // Validate 171 // Validate
166 if err := cfg.Validate(); err != nil { 172 if err := cfg.Validate(); err != nil {
167 return nil, fmt.Errorf("invalid configuration: %w", err) 173 return nil, fmt.Errorf("invalid configuration: %w", err)
@@ -170,6 +176,41 @@ func Load(filename string) (*Config, error) {
170 return cfg, nil 176 return cfg, nil
171} 177}
172 178
179// normalizeNpubs converts all npub (bech32) pubkeys to hex format.
180// Config only accepts npub format (human-readable), which is converted
181// to hex format (computer-readable) for internal use.
182func normalizeNpubs(cfg *Config) error {
183 if len(cfg.Auth.AllowedNpubs) == 0 {
184 return nil
185 }
186
187 normalized := make([]string, 0, len(cfg.Auth.AllowedNpubs))
188 for _, npub := range cfg.Auth.AllowedNpubs {
189 // Skip empty strings
190 npub = strings.TrimSpace(npub)
191 if npub == "" {
192 continue
193 }
194
195 // Validate npub format
196 if !strings.HasPrefix(npub, "npub1") {
197 return fmt.Errorf("invalid npub format %q: must start with 'npub1'", npub)
198 }
199
200 // Parse npub to get hex pubkey
201 key, err := nostr.ParsePublicKey(npub)
202 if err != nil {
203 return fmt.Errorf("invalid npub %q: %w", npub, err)
204 }
205
206 // Get the hex representation for internal use
207 normalized = append(normalized, key.Public())
208 }
209
210 cfg.Auth.AllowedNpubs = normalized
211 return nil
212}
213
173// Validate validates the configuration. 214// Validate validates the configuration.
174func (c *Config) Validate() error { 215func (c *Config) Validate() error {
175 // Validate server addresses 216 // Validate server addresses
@@ -251,8 +292,8 @@ func applyEnvOverrides(cfg *Config) {
251 cfg.Auth.TimestampWindow = n 292 cfg.Auth.TimestampWindow = n
252 } 293 }
253 } 294 }
254 if val := os.Getenv("MUXSTR_AUTH_ALLOWED_PUBKEYS"); val != "" { 295 if val := os.Getenv("MUXSTR_AUTH_ALLOWED_NPUBS"); val != "" {
255 cfg.Auth.AllowedPubkeys = strings.Split(val, ",") 296 cfg.Auth.AllowedNpubs = strings.Split(val, ",")
256 } 297 }
257 298
258 // Rate limit 299 // Rate limit
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index e1df1aa..5fa159e 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -2,6 +2,7 @@ package config
2 2
3import ( 3import (
4 "os" 4 "os"
5 "strings"
5 "testing" 6 "testing"
6 "time" 7 "time"
7) 8)
@@ -240,6 +241,102 @@ func TestSaveAndLoad(t *testing.T) {
240 } 241 }
241} 242}
242 243
244func TestNpubNormalization(t *testing.T) {
245 // Create a test key to get a valid npub
246 tmpfile, err := os.CreateTemp("", "config-*.yaml")
247 if err != nil {
248 t.Fatal(err)
249 }
250 defer os.Remove(tmpfile.Name())
251
252 // Use a real npub for testing
253 configData := `
254server:
255 grpc_addr: ":50051"
256 http_addr: ":8080"
257
258database:
259 path: "test.db"
260
261auth:
262 enabled: true
263 allowed_npubs:
264 - npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6
265 - npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft
266`
267
268 if _, err := tmpfile.Write([]byte(configData)); err != nil {
269 t.Fatal(err)
270 }
271 tmpfile.Close()
272
273 cfg, err := Load(tmpfile.Name())
274 if err != nil {
275 t.Fatalf("failed to load config: %v", err)
276 }
277
278 // Verify npubs were normalized to hex
279 if len(cfg.Auth.AllowedNpubs) != 2 {
280 t.Errorf("expected 2 allowed npubs, got %d", len(cfg.Auth.AllowedNpubs))
281 }
282
283 // Check that they're hex format (64 chars, not npub1...)
284 for i, pubkey := range cfg.Auth.AllowedNpubs {
285 if len(pubkey) != 64 {
286 t.Errorf("npub %d: expected 64 hex chars, got %d", i, len(pubkey))
287 }
288 if pubkey[:5] == "npub1" {
289 t.Errorf("npub %d: should be normalized to hex, still in npub format", i)
290 }
291 }
292
293 // Verify the actual hex values
294 expectedHex1 := "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"
295 expectedHex2 := "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"
296
297 if cfg.Auth.AllowedNpubs[0] != expectedHex1 {
298 t.Errorf("npub 0: expected %s, got %s", expectedHex1, cfg.Auth.AllowedNpubs[0])
299 }
300 if cfg.Auth.AllowedNpubs[1] != expectedHex2 {
301 t.Errorf("npub 1: expected %s, got %s", expectedHex2, cfg.Auth.AllowedNpubs[1])
302 }
303}
304
305func TestNpubValidation(t *testing.T) {
306 tmpfile, err := os.CreateTemp("", "config-*.yaml")
307 if err != nil {
308 t.Fatal(err)
309 }
310 defer os.Remove(tmpfile.Name())
311
312 // Invalid: hex format instead of npub
313 configData := `
314server:
315 grpc_addr: ":50051"
316 http_addr: ":8080"
317
318database:
319 path: "test.db"
320
321auth:
322 allowed_npubs:
323 - 3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d
324`
325
326 if _, err := tmpfile.Write([]byte(configData)); err != nil {
327 t.Fatal(err)
328 }
329 tmpfile.Close()
330
331 _, err = Load(tmpfile.Name())
332 if err == nil {
333 t.Error("expected error for hex format in allowed_npubs, got nil")
334 }
335 if err != nil && !strings.Contains(err.Error(), "must start with 'npub1'") {
336 t.Errorf("expected 'must start with npub1' error, got: %v", err)
337 }
338}
339
243func TestDurationParsing(t *testing.T) { 340func TestDurationParsing(t *testing.T) {
244 // Create config with durations 341 // Create config with durations
245 tmpfile, err := os.CreateTemp("", "config-*.yaml") 342 tmpfile, err := os.CreateTemp("", "config-*.yaml")