diff options
| author | bndw <ben@bdw.to> | 2026-02-14 09:58:28 -0800 |
|---|---|---|
| committer | bndw <ben@bdw.to> | 2026-02-14 09:58:28 -0800 |
| commit | d30459513ec44ab298fafd1bfe0edc08d6ab62e4 (patch) | |
| tree | 1e4442f940c11544cd60b6bf72f2038338da67ce | |
| parent | fe3708eaf495613cc6e2340b821795f25811d6ed (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/Caddyfile | 18 | ||||
| -rw-r--r-- | .ship/service | 17 | ||||
| -rw-r--r-- | Makefile | 5 | ||||
| -rw-r--r-- | internal/auth/README.md | 4 | ||||
| -rw-r--r-- | internal/auth/interceptor.go | 13 | ||||
| -rw-r--r-- | internal/config/README.md | 12 | ||||
| -rw-r--r-- | internal/config/config.go | 47 | ||||
| -rw-r--r-- | internal/config/config_test.go | 97 |
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 @@ | |||
| 1 | nostr-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] | ||
| 2 | Description=nostr-grpc | ||
| 3 | After=network.target | ||
| 4 | |||
| 5 | [Service] | ||
| 6 | Type=simple | ||
| 7 | User=nostr-grpc | ||
| 8 | WorkingDirectory=/var/lib/nostr-grpc | ||
| 9 | EnvironmentFile=/etc/ship/env/nostr-grpc.env | ||
| 10 | ExecStart=/usr/local/bin/nostr-grpc -db relay.db -grpc-addr localhost:50051 -ws-addr localhost:8006 -public-url nostr-grpc.x.bdw.to | ||
| 11 | Restart=always | ||
| 12 | RestartSec=5s | ||
| 13 | NoNewPrivileges=true | ||
| 14 | PrivateTmp=true | ||
| 15 | |||
| 16 | [Install] | ||
| 17 | WantedBy=multi-user.target | ||
| @@ -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 |
| 4 | proto: | 4 | proto: |
| @@ -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 | ||
| 35 | deploy: | ||
| 36 | ship --name nostr-grpc --binary ./bin/relay | ||
| 37 | |||
| 35 | # Install buf (if not already installed) | 38 | # Install buf (if not already installed) |
| 36 | install-buf: | 39 | install-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: | |||
| 217 | Complex types: | 221 | Complex types: |
| 218 | 222 | ||
| 219 | ```bash | 223 | ```bash |
| 220 | # Lists (comma-separated) | 224 | # Lists (comma-separated, npub format) |
| 221 | export MUXSTR_AUTH_ALLOWED_PUBKEYS="pubkey1,pubkey2,pubkey3" | 225 | export MUXSTR_AUTH_ALLOWED_NPUBS="npub1...,npub1...,npub1..." |
| 222 | 226 | ||
| 223 | # Durations | 227 | # Durations |
| 224 | export MUXSTR_SERVER_READ_TIMEOUT="30s" | 228 | export 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. | ||
| 182 | func 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. |
| 174 | func (c *Config) Validate() error { | 215 | func (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 | ||
| 3 | import ( | 3 | import ( |
| 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 | ||
| 244 | func 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 := ` | ||
| 254 | server: | ||
| 255 | grpc_addr: ":50051" | ||
| 256 | http_addr: ":8080" | ||
| 257 | |||
| 258 | database: | ||
| 259 | path: "test.db" | ||
| 260 | |||
| 261 | auth: | ||
| 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 | |||
| 305 | func 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 := ` | ||
| 314 | server: | ||
| 315 | grpc_addr: ":50051" | ||
| 316 | http_addr: ":8080" | ||
| 317 | |||
| 318 | database: | ||
| 319 | path: "test.db" | ||
| 320 | |||
| 321 | auth: | ||
| 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 | |||
| 243 | func TestDurationParsing(t *testing.T) { | 340 | func 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") |
