From 5d21632ea70e1c7de7becb7ab6227b06b1535a83 Mon Sep 17 00:00:00 2001 From: bndw Date: Sat, 14 Feb 2026 10:02:52 -0800 Subject: feat: add separate read/write allowlists for granular access control - Split allowed_npubs into allowed_npubs_read and allowed_npubs_write - Write operations: Publish, Delete, Create, Update, Insert, Remove, Set, Put - Read operations: everything else (Query, Subscribe, Get, List, etc.) - Auth interceptor checks appropriate list based on method type - Enables common patterns: - Public relay: only some can write, everyone can read - Private relay: restricted read and write - Open relay: everyone can read and write - Updated config, docs, and comprehensive tests Use cases: "only some can write, everyone can read" --- internal/config/README.md | 24 ++++++-- internal/config/config.go | 52 +++++++++++----- internal/config/config_test.go | 136 ++++++++++++++++++++++++++++++----------- 3 files changed, 158 insertions(+), 54 deletions(-) (limited to 'internal/config') diff --git a/internal/config/README.md b/internal/config/README.md index dbb8760..7deb38f 100644 --- a/internal/config/README.md +++ b/internal/config/README.md @@ -90,12 +90,25 @@ auth: # Timestamp window in seconds for replay protection timestamp_window: 60 - # Allowed npubs (optional, whitelist) - # If empty, all valid signatures are accepted + # Allowed npubs for read operations (optional, whitelist) + # If empty, all valid signatures are accepted for reads # Use npub format only (e.g., npub1...) - allowed_npubs: [] + allowed_npubs_read: [] + + # Allowed npubs for write operations (optional, whitelist) + # If empty, all valid signatures are accepted for writes + # Use npub format only (e.g., npub1...) + allowed_npubs_write: [] + + # Example use cases: + # - Public relay: allowed_npubs_write (only some can publish), empty read (everyone can read) + # - Private relay: both lists populated (restricted read and write) + # - Open relay: both lists empty (everyone can read and write) + # # Example: - # allowed_npubs: + # allowed_npubs_read: + # - npub1a2b3c4d5e6f... + # allowed_npubs_write: # - npub1a2b3c4d5e6f... # Skip authentication for these methods @@ -222,7 +235,8 @@ Complex types: ```bash # Lists (comma-separated, npub format) -export MUXSTR_AUTH_ALLOWED_NPUBS="npub1...,npub1...,npub1..." +export MUXSTR_AUTH_ALLOWED_NPUBS_READ="npub1...,npub1..." +export MUXSTR_AUTH_ALLOWED_NPUBS_WRITE="npub1..." # Durations export MUXSTR_SERVER_READ_TIMEOUT="30s" diff --git a/internal/config/config.go b/internal/config/config.go index 0566537..3e52272 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -39,11 +39,12 @@ type DatabaseConfig struct { // AuthConfig holds authentication configuration. type AuthConfig struct { - Enabled bool `yaml:"enabled"` - Required bool `yaml:"required"` - TimestampWindow int64 `yaml:"timestamp_window"` - AllowedNpubs []string `yaml:"allowed_npubs"` // npub format only (bech32) - normalized to hex internally - SkipMethods []string `yaml:"skip_methods"` + Enabled bool `yaml:"enabled"` + Required bool `yaml:"required"` + TimestampWindow int64 `yaml:"timestamp_window"` + AllowedNpubsRead []string `yaml:"allowed_npubs_read"` // npub format only (bech32) - normalized to hex internally + AllowedNpubsWrite []string `yaml:"allowed_npubs_write"` // npub format only (bech32) - normalized to hex internally + SkipMethods []string `yaml:"skip_methods"` } // RateLimitConfig holds rate limiting configuration. @@ -180,12 +181,31 @@ func Load(filename string) (*Config, error) { // 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 + var err error + + // Normalize read allowlist + cfg.Auth.AllowedNpubsRead, err = normalizeNpubList(cfg.Auth.AllowedNpubsRead) + if err != nil { + return fmt.Errorf("allowed_npubs_read: %w", err) + } + + // Normalize write allowlist + cfg.Auth.AllowedNpubsWrite, err = normalizeNpubList(cfg.Auth.AllowedNpubsWrite) + if err != nil { + return fmt.Errorf("allowed_npubs_write: %w", err) + } + + return nil +} + +// normalizeNpubList converts a list of npubs to hex pubkeys. +func normalizeNpubList(npubs []string) ([]string, error) { + if len(npubs) == 0 { + return nil, nil } - normalized := make([]string, 0, len(cfg.Auth.AllowedNpubs)) - for _, npub := range cfg.Auth.AllowedNpubs { + normalized := make([]string, 0, len(npubs)) + for _, npub := range npubs { // Skip empty strings npub = strings.TrimSpace(npub) if npub == "" { @@ -194,21 +214,20 @@ func normalizeNpubs(cfg *Config) error { // Validate npub format if !strings.HasPrefix(npub, "npub1") { - return fmt.Errorf("invalid npub format %q: must start with 'npub1'", npub) + return nil, 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) + return nil, 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 + return normalized, nil } // Validate validates the configuration. @@ -292,8 +311,11 @@ func applyEnvOverrides(cfg *Config) { cfg.Auth.TimestampWindow = n } } - if val := os.Getenv("MUXSTR_AUTH_ALLOWED_NPUBS"); val != "" { - cfg.Auth.AllowedNpubs = strings.Split(val, ",") + if val := os.Getenv("MUXSTR_AUTH_ALLOWED_NPUBS_READ"); val != "" { + cfg.Auth.AllowedNpubsRead = strings.Split(val, ",") + } + if val := os.Getenv("MUXSTR_AUTH_ALLOWED_NPUBS_WRITE"); val != "" { + cfg.Auth.AllowedNpubsWrite = strings.Split(val, ",") } // Rate limit diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5fa159e..c0d4555 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -260,9 +260,11 @@ database: auth: enabled: true - allowed_npubs: + allowed_npubs_read: - npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6 - npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft + allowed_npubs_write: + - npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6 ` if _, err := tmpfile.Write([]byte(configData)); err != nil { @@ -275,18 +277,32 @@ auth: 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)) + // Verify read npubs were normalized to hex + if len(cfg.Auth.AllowedNpubsRead) != 2 { + t.Errorf("expected 2 allowed npubs for read, got %d", len(cfg.Auth.AllowedNpubsRead)) + } + + // Verify write npubs were normalized to hex + if len(cfg.Auth.AllowedNpubsWrite) != 1 { + t.Errorf("expected 1 allowed npub for write, got %d", len(cfg.Auth.AllowedNpubsWrite)) } // Check that they're hex format (64 chars, not npub1...) - for i, pubkey := range cfg.Auth.AllowedNpubs { + for i, pubkey := range cfg.Auth.AllowedNpubsRead { + if len(pubkey) != 64 { + t.Errorf("read npub %d: expected 64 hex chars, got %d", i, len(pubkey)) + } + if len(pubkey) >= 5 && pubkey[:5] == "npub1" { + t.Errorf("read npub %d: should be normalized to hex, still in npub format", i) + } + } + + for i, pubkey := range cfg.Auth.AllowedNpubsWrite { if len(pubkey) != 64 { - t.Errorf("npub %d: expected 64 hex chars, got %d", i, len(pubkey)) + t.Errorf("write 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) + if len(pubkey) >= 5 && pubkey[:5] == "npub1" { + t.Errorf("write npub %d: should be normalized to hex, still in npub format", i) } } @@ -294,46 +310,98 @@ auth: 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.AllowedNpubsRead[0] != expectedHex1 { + t.Errorf("read npub 0: expected %s, got %s", expectedHex1, cfg.Auth.AllowedNpubsRead[0]) + } + if cfg.Auth.AllowedNpubsRead[1] != expectedHex2 { + t.Errorf("read npub 1: expected %s, got %s", expectedHex2, cfg.Auth.AllowedNpubsRead[1]) } - if cfg.Auth.AllowedNpubs[1] != expectedHex2 { - t.Errorf("npub 1: expected %s, got %s", expectedHex2, cfg.Auth.AllowedNpubs[1]) + if cfg.Auth.AllowedNpubsWrite[0] != expectedHex1 { + t.Errorf("write npub 0: expected %s, got %s", expectedHex1, cfg.Auth.AllowedNpubsWrite[0]) } } 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 := ` + tests := []struct { + name string + config string + expectError bool + errorMsg string + }{ + { + name: "invalid hex in read list", + config: ` server: grpc_addr: ":50051" http_addr: ":8080" - database: path: "test.db" - auth: - allowed_npubs: + allowed_npubs_read: - 3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d -` - - if _, err := tmpfile.Write([]byte(configData)); err != nil { - t.Fatal(err) +`, + expectError: true, + errorMsg: "must start with 'npub1'", + }, + { + name: "invalid hex in write list", + config: ` +server: + grpc_addr: ":50051" + http_addr: ":8080" +database: + path: "test.db" +auth: + allowed_npubs_write: + - 3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d +`, + expectError: true, + errorMsg: "must start with 'npub1'", + }, + { + name: "valid npub lists", + config: ` +server: + grpc_addr: ":50051" + http_addr: ":8080" +database: + path: "test.db" +auth: + allowed_npubs_read: + - npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6 + allowed_npubs_write: + - npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft +`, + expectError: false, + }, } - 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) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpfile, err := os.CreateTemp("", "config-*.yaml") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpfile.Name()) + + if _, err := tmpfile.Write([]byte(tt.config)); err != nil { + t.Fatal(err) + } + tmpfile.Close() + + _, err = Load(tmpfile.Name()) + if tt.expectError { + if err == nil { + t.Error("expected error, got nil") + } else if !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("expected error containing %q, got: %v", tt.errorMsg, err) + } + } else { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + } + }) } } -- cgit v1.2.3