From d30459513ec44ab298fafd1bfe0edc08d6ab62e4 Mon Sep 17 00:00:00 2001 From: bndw Date: Sat, 14 Feb 2026 09:58:28 -0800 Subject: 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. --- .ship/Caddyfile | 18 ++++++++ .ship/service | 17 ++++++++ Makefile | 5 ++- internal/auth/README.md | 4 +- internal/auth/interceptor.go | 13 +++--- internal/config/README.md | 12 ++++-- internal/config/config.go | 47 ++++++++++++++++++-- internal/config/config_test.go | 97 ++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 198 insertions(+), 15 deletions(-) create mode 100644 .ship/Caddyfile create mode 100644 .ship/service diff --git a/.ship/Caddyfile b/.ship/Caddyfile new file mode 100644 index 0000000..88ed6d3 --- /dev/null +++ b/.ship/Caddyfile @@ -0,0 +1,18 @@ +nostr-grpc.x.bdw.to { + # Route native gRPC to port 50051 + @grpc { + header Content-Type application/grpc* + } + reverse_proxy @grpc localhost:50051 { + transport http { + versions h2c + } + } + + # Everything else (Connect, WebSocket, HTML) to port 8006 + reverse_proxy localhost:8006 { + # Enable WebSocket support + header_up Upgrade {http.request.header.Upgrade} + header_up Connection {http.request.header.Connection} + } +} diff --git a/.ship/service b/.ship/service new file mode 100644 index 0000000..9305ef4 --- /dev/null +++ b/.ship/service @@ -0,0 +1,17 @@ +[Unit] +Description=nostr-grpc +After=network.target + +[Service] +Type=simple +User=nostr-grpc +WorkingDirectory=/var/lib/nostr-grpc +EnvironmentFile=/etc/ship/env/nostr-grpc.env +ExecStart=/usr/local/bin/nostr-grpc -db relay.db -grpc-addr localhost:50051 -ws-addr localhost:8006 -public-url nostr-grpc.x.bdw.to +Restart=always +RestartSec=5s +NoNewPrivileges=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/Makefile b/Makefile index 87ede58..35ea9f7 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: proto proto-lint proto-breaking test build clean +.PHONY: proto proto-lint proto-breaking test build clean deploy # Generate proto files proto: @@ -32,6 +32,9 @@ clean: rm -rf api/ rm -f bin/relay +deploy: + ship --name nostr-grpc --binary ./bin/relay + # Install buf (if not already installed) install-buf: @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{ - **`TimestampWindow`**: Maximum age of events in seconds (default: 60) - **`Required`**: Whether to reject unauthenticated requests (default: false) - **`ValidatePayload`**: Whether to verify payload hash when present (default: false) -- **`AllowedPubkeys`**: Optional whitelist of allowed pubkeys (nil = allow all) +- **`AllowedNpubs`**: Optional whitelist of allowed pubkeys (nil = allow all) + - Config accepts npub format only (human-readable bech32) + - Automatically normalized to hex format (computer-readable) at config load time ### NostrCredentials Options 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 { // Default: false ValidatePayload bool - // AllowedPubkeys is an optional whitelist of allowed pubkeys. + // AllowedNpubs is an optional whitelist of allowed pubkeys (hex format). + // Config accepts npub format only, normalized to hex at load time. // If nil or empty, all valid signatures are accepted. // Default: nil (allow all) - AllowedPubkeys []string + AllowedNpubs []string // SkipMethods is a list of gRPC methods that bypass authentication. // Useful for public endpoints like health checks or relay info. @@ -53,7 +54,7 @@ func DefaultInterceptorOptions() *InterceptorOptions { TimestampWindow: 60, Required: false, ValidatePayload: false, - AllowedPubkeys: nil, + AllowedNpubs: nil, SkipMethods: nil, } } @@ -168,9 +169,9 @@ func validateAuthFromContext(ctx context.Context, method string, opts *Intercept // Extract pubkey pubkey := ExtractPubkey(event) - // Check whitelist if configured - if len(opts.AllowedPubkeys) > 0 { - if !contains(opts.AllowedPubkeys, pubkey) { + // Check whitelist if configured (all values are already normalized to hex) + if len(opts.AllowedNpubs) > 0 { + if !contains(opts.AllowedNpubs, pubkey) { return "", fmt.Errorf("pubkey not in whitelist") } } 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: # Timestamp window in seconds for replay protection timestamp_window: 60 - # Allowed pubkeys (optional, whitelist) + # Allowed npubs (optional, whitelist) # If empty, all valid signatures are accepted - allowed_pubkeys: [] + # Use npub format only (e.g., npub1...) + allowed_npubs: [] + # Example: + # allowed_npubs: + # - npub1a2b3c4d5e6f... # Skip authentication for these methods skip_methods: @@ -217,8 +221,8 @@ Examples: Complex types: ```bash -# Lists (comma-separated) -export MUXSTR_AUTH_ALLOWED_PUBKEYS="pubkey1,pubkey2,pubkey3" +# Lists (comma-separated, npub format) +export MUXSTR_AUTH_ALLOWED_NPUBS="npub1...,npub1...,npub1..." # Durations 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 ( "strings" "time" + "northwest.io/muxstr/internal/nostr" "gopkg.in/yaml.v3" ) @@ -41,7 +42,7 @@ type AuthConfig struct { Enabled bool `yaml:"enabled"` Required bool `yaml:"required"` TimestampWindow int64 `yaml:"timestamp_window"` - AllowedPubkeys []string `yaml:"allowed_pubkeys"` + AllowedNpubs []string `yaml:"allowed_npubs"` // npub format only (bech32) - normalized to hex internally SkipMethods []string `yaml:"skip_methods"` } @@ -162,6 +163,11 @@ func Load(filename string) (*Config, error) { // Apply environment variable overrides applyEnvOverrides(cfg) + // Normalize npubs to hex pubkeys + if err := normalizeNpubs(cfg); err != nil { + return nil, fmt.Errorf("failed to normalize npubs: %w", err) + } + // Validate if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("invalid configuration: %w", err) @@ -170,6 +176,41 @@ func Load(filename string) (*Config, error) { return cfg, nil } +// normalizeNpubs converts all npub (bech32) pubkeys to hex format. +// Config only accepts npub format (human-readable), which is converted +// to hex format (computer-readable) for internal use. +func normalizeNpubs(cfg *Config) error { + if len(cfg.Auth.AllowedNpubs) == 0 { + return nil + } + + normalized := make([]string, 0, len(cfg.Auth.AllowedNpubs)) + for _, npub := range cfg.Auth.AllowedNpubs { + // Skip empty strings + npub = strings.TrimSpace(npub) + if npub == "" { + continue + } + + // Validate npub format + if !strings.HasPrefix(npub, "npub1") { + return fmt.Errorf("invalid npub format %q: must start with 'npub1'", npub) + } + + // Parse npub to get hex pubkey + key, err := nostr.ParsePublicKey(npub) + if err != nil { + return fmt.Errorf("invalid npub %q: %w", npub, err) + } + + // Get the hex representation for internal use + normalized = append(normalized, key.Public()) + } + + cfg.Auth.AllowedNpubs = normalized + return nil +} + // Validate validates the configuration. func (c *Config) Validate() error { // Validate server addresses @@ -251,8 +292,8 @@ func applyEnvOverrides(cfg *Config) { cfg.Auth.TimestampWindow = n } } - if val := os.Getenv("MUXSTR_AUTH_ALLOWED_PUBKEYS"); val != "" { - cfg.Auth.AllowedPubkeys = strings.Split(val, ",") + if val := os.Getenv("MUXSTR_AUTH_ALLOWED_NPUBS"); val != "" { + cfg.Auth.AllowedNpubs = strings.Split(val, ",") } // 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 import ( "os" + "strings" "testing" "time" ) @@ -240,6 +241,102 @@ func TestSaveAndLoad(t *testing.T) { } } +func TestNpubNormalization(t *testing.T) { + // Create a test key to get a valid npub + tmpfile, err := os.CreateTemp("", "config-*.yaml") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpfile.Name()) + + // Use a real npub for testing + configData := ` +server: + grpc_addr: ":50051" + http_addr: ":8080" + +database: + path: "test.db" + +auth: + enabled: true + allowed_npubs: + - npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6 + - npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft +` + + if _, err := tmpfile.Write([]byte(configData)); err != nil { + t.Fatal(err) + } + tmpfile.Close() + + cfg, err := Load(tmpfile.Name()) + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + + // Verify npubs were normalized to hex + if len(cfg.Auth.AllowedNpubs) != 2 { + t.Errorf("expected 2 allowed npubs, got %d", len(cfg.Auth.AllowedNpubs)) + } + + // Check that they're hex format (64 chars, not npub1...) + for i, pubkey := range cfg.Auth.AllowedNpubs { + if len(pubkey) != 64 { + t.Errorf("npub %d: expected 64 hex chars, got %d", i, len(pubkey)) + } + if pubkey[:5] == "npub1" { + t.Errorf("npub %d: should be normalized to hex, still in npub format", i) + } + } + + // Verify the actual hex values + expectedHex1 := "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" + expectedHex2 := "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52" + + if cfg.Auth.AllowedNpubs[0] != expectedHex1 { + t.Errorf("npub 0: expected %s, got %s", expectedHex1, cfg.Auth.AllowedNpubs[0]) + } + if cfg.Auth.AllowedNpubs[1] != expectedHex2 { + t.Errorf("npub 1: expected %s, got %s", expectedHex2, cfg.Auth.AllowedNpubs[1]) + } +} + +func TestNpubValidation(t *testing.T) { + tmpfile, err := os.CreateTemp("", "config-*.yaml") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpfile.Name()) + + // Invalid: hex format instead of npub + configData := ` +server: + grpc_addr: ":50051" + http_addr: ":8080" + +database: + path: "test.db" + +auth: + allowed_npubs: + - 3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d +` + + if _, err := tmpfile.Write([]byte(configData)); err != nil { + t.Fatal(err) + } + tmpfile.Close() + + _, err = Load(tmpfile.Name()) + if err == nil { + t.Error("expected error for hex format in allowed_npubs, got nil") + } + if err != nil && !strings.Contains(err.Error(), "must start with 'npub1'") { + t.Errorf("expected 'must start with npub1' error, got: %v", err) + } +} + func TestDurationParsing(t *testing.T) { // Create config with durations tmpfile, err := os.CreateTemp("", "config-*.yaml") -- cgit v1.2.3