diff options
| author | bndw <ben@bdw.to> | 2026-02-14 10:02:52 -0800 |
|---|---|---|
| committer | bndw <ben@bdw.to> | 2026-02-14 10:02:52 -0800 |
| commit | 5d21632ea70e1c7de7becb7ab6227b06b1535a83 (patch) | |
| tree | e63bcbe8cdf7dc888ca0e3476ad529690a0a44a8 /internal | |
| parent | d30459513ec44ab298fafd1bfe0edc08d6ab62e4 (diff) | |
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"
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/auth/README.md | 13 | ||||
| -rw-r--r-- | internal/auth/auth_test.go | 38 | ||||
| -rw-r--r-- | internal/auth/interceptor.go | 67 | ||||
| -rw-r--r-- | internal/config/README.md | 24 | ||||
| -rw-r--r-- | internal/config/config.go | 52 | ||||
| -rw-r--r-- | internal/config/config_test.go | 136 |
6 files changed, 263 insertions, 67 deletions
diff --git a/internal/auth/README.md b/internal/auth/README.md index df0de6a..366e110 100644 --- a/internal/auth/README.md +++ b/internal/auth/README.md | |||
| @@ -209,9 +209,20 @@ 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 | - **`AllowedNpubs`**: Optional whitelist of allowed pubkeys (nil = allow all) | 212 | - **`AllowedNpubsRead`**: Optional whitelist of allowed pubkeys for read operations (nil = allow all) |
| 213 | - Config accepts npub format only (human-readable bech32) | 213 | - Config accepts npub format only (human-readable bech32) |
| 214 | - Automatically normalized to hex format (computer-readable) at config load time | 214 | - Automatically normalized to hex format (computer-readable) at config load time |
| 215 | - Controls access to Query, Get, List, Subscribe, and other read methods | ||
| 216 | - **`AllowedNpubsWrite`**: Optional whitelist of allowed pubkeys for write operations (nil = allow all) | ||
| 217 | - Config accepts npub format only (human-readable bech32) | ||
| 218 | - Automatically normalized to hex format (computer-readable) at config load time | ||
| 219 | - Controls access to Publish, Delete, Create, Update, and other write methods | ||
| 220 | |||
| 221 | **Access Control Patterns:** | ||
| 222 | - **Public relay**: Set `AllowedNpubsWrite` (only some can publish), leave `AllowedNpubsRead` empty (everyone can read) | ||
| 223 | - **Private relay**: Set both lists (restricted read and write access) | ||
| 224 | - **Open relay**: Leave both empty (everyone can read and write) | ||
| 225 | - **Read-only relay**: Set `AllowedNpubsRead`, block all writes | ||
| 215 | 226 | ||
| 216 | ### NostrCredentials Options | 227 | ### NostrCredentials Options |
| 217 | 228 | ||
diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 1f0efee..7a0da19 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go | |||
| @@ -304,3 +304,41 @@ func TestHashPayload(t *testing.T) { | |||
| 304 | t.Error("different payloads produced same hash") | 304 | t.Error("different payloads produced same hash") |
| 305 | } | 305 | } |
| 306 | } | 306 | } |
| 307 | |||
| 308 | func TestIsWriteMethod(t *testing.T) { | ||
| 309 | tests := []struct { | ||
| 310 | method string | ||
| 311 | want bool | ||
| 312 | }{ | ||
| 313 | // Write methods | ||
| 314 | {"/nostr.v1.NostrRelay/PublishEvent", true}, | ||
| 315 | {"/nostr.v1.NostrRelay/DeleteEvent", true}, | ||
| 316 | {"/admin.v1.Admin/CreateUser", true}, | ||
| 317 | {"/admin.v1.Admin/UpdateSettings", true}, | ||
| 318 | {"/data.v1.Data/InsertRecord", true}, | ||
| 319 | {"/data.v1.Data/RemoveItem", true}, | ||
| 320 | {"/storage.v1.Storage/SetValue", true}, | ||
| 321 | {"/storage.v1.Storage/PutObject", true}, | ||
| 322 | |||
| 323 | // Read methods | ||
| 324 | {"/nostr.v1.NostrRelay/QueryEvents", false}, | ||
| 325 | {"/nostr.v1.NostrRelay/Subscribe", false}, | ||
| 326 | {"/nostr.v1.NostrRelay/GetEvent", false}, | ||
| 327 | {"/admin.v1.Admin/ListUsers", false}, | ||
| 328 | {"/health.v1.Health/Check", false}, | ||
| 329 | {"/info.v1.Info/GetRelayInfo", false}, | ||
| 330 | |||
| 331 | // Edge cases | ||
| 332 | {"", false}, | ||
| 333 | {"/", false}, | ||
| 334 | } | ||
| 335 | |||
| 336 | for _, tt := range tests { | ||
| 337 | t.Run(tt.method, func(t *testing.T) { | ||
| 338 | got := isWriteMethod(tt.method) | ||
| 339 | if got != tt.want { | ||
| 340 | t.Errorf("isWriteMethod(%q) = %v, want %v", tt.method, got, tt.want) | ||
| 341 | } | ||
| 342 | }) | ||
| 343 | } | ||
| 344 | } | ||
diff --git a/internal/auth/interceptor.go b/internal/auth/interceptor.go index 7d785bf..66880a7 100644 --- a/internal/auth/interceptor.go +++ b/internal/auth/interceptor.go | |||
| @@ -3,6 +3,7 @@ package auth | |||
| 3 | import ( | 3 | import ( |
| 4 | "context" | 4 | "context" |
| 5 | "fmt" | 5 | "fmt" |
| 6 | "strings" | ||
| 6 | 7 | ||
| 7 | "google.golang.org/grpc" | 8 | "google.golang.org/grpc" |
| 8 | "google.golang.org/grpc/codes" | 9 | "google.golang.org/grpc/codes" |
| @@ -35,11 +36,17 @@ type InterceptorOptions struct { | |||
| 35 | // Default: false | 36 | // Default: false |
| 36 | ValidatePayload bool | 37 | ValidatePayload bool |
| 37 | 38 | ||
| 38 | // AllowedNpubs is an optional whitelist of allowed pubkeys (hex format). | 39 | // AllowedNpubsRead is an optional whitelist of allowed pubkeys for read operations (hex format). |
| 39 | // Config accepts npub format only, normalized to hex at load time. | 40 | // Config accepts npub format only, normalized to hex at load time. |
| 40 | // If nil or empty, all valid signatures are accepted. | 41 | // If nil or empty, all valid signatures are accepted for reads. |
| 41 | // Default: nil (allow all) | 42 | // Default: nil (allow all) |
| 42 | AllowedNpubs []string | 43 | AllowedNpubsRead []string |
| 44 | |||
| 45 | // AllowedNpubsWrite is an optional whitelist of allowed pubkeys for write operations (hex format). | ||
| 46 | // Config accepts npub format only, normalized to hex at load time. | ||
| 47 | // If nil or empty, all valid signatures are accepted for writes. | ||
| 48 | // Default: nil (allow all) | ||
| 49 | AllowedNpubsWrite []string | ||
| 43 | 50 | ||
| 44 | // SkipMethods is a list of gRPC methods that bypass authentication. | 51 | // SkipMethods is a list of gRPC methods that bypass authentication. |
| 45 | // Useful for public endpoints like health checks or relay info. | 52 | // Useful for public endpoints like health checks or relay info. |
| @@ -51,11 +58,12 @@ type InterceptorOptions struct { | |||
| 51 | // DefaultInterceptorOptions returns the default configuration. | 58 | // DefaultInterceptorOptions returns the default configuration. |
| 52 | func DefaultInterceptorOptions() *InterceptorOptions { | 59 | func DefaultInterceptorOptions() *InterceptorOptions { |
| 53 | return &InterceptorOptions{ | 60 | return &InterceptorOptions{ |
| 54 | TimestampWindow: 60, | 61 | TimestampWindow: 60, |
| 55 | Required: false, | 62 | Required: false, |
| 56 | ValidatePayload: false, | 63 | ValidatePayload: false, |
| 57 | AllowedNpubs: nil, | 64 | AllowedNpubsRead: nil, |
| 58 | SkipMethods: nil, | 65 | AllowedNpubsWrite: nil, |
| 66 | SkipMethods: nil, | ||
| 59 | } | 67 | } |
| 60 | } | 68 | } |
| 61 | 69 | ||
| @@ -169,16 +177,51 @@ func validateAuthFromContext(ctx context.Context, method string, opts *Intercept | |||
| 169 | // Extract pubkey | 177 | // Extract pubkey |
| 170 | pubkey := ExtractPubkey(event) | 178 | pubkey := ExtractPubkey(event) |
| 171 | 179 | ||
| 172 | // Check whitelist if configured (all values are already normalized to hex) | 180 | // Check whitelist based on operation type (all values are already normalized to hex) |
| 173 | if len(opts.AllowedNpubs) > 0 { | 181 | if isWriteMethod(method) { |
| 174 | if !contains(opts.AllowedNpubs, pubkey) { | 182 | // Write operation - check write allowlist |
| 175 | return "", fmt.Errorf("pubkey not in whitelist") | 183 | if len(opts.AllowedNpubsWrite) > 0 { |
| 184 | if !contains(opts.AllowedNpubsWrite, pubkey) { | ||
| 185 | return "", fmt.Errorf("pubkey not authorized for write operations") | ||
| 186 | } | ||
| 187 | } | ||
| 188 | } else { | ||
| 189 | // Read operation - check read allowlist | ||
| 190 | if len(opts.AllowedNpubsRead) > 0 { | ||
| 191 | if !contains(opts.AllowedNpubsRead, pubkey) { | ||
| 192 | return "", fmt.Errorf("pubkey not authorized for read operations") | ||
| 193 | } | ||
| 176 | } | 194 | } |
| 177 | } | 195 | } |
| 178 | 196 | ||
| 179 | return pubkey, nil | 197 | return pubkey, nil |
| 180 | } | 198 | } |
| 181 | 199 | ||
| 200 | // isWriteMethod determines if a gRPC method is a write operation. | ||
| 201 | // Write operations modify state (Publish, Delete, Create, Update, etc.) | ||
| 202 | // Read operations query state (Query, Get, List, Subscribe, etc.) | ||
| 203 | func isWriteMethod(method string) bool { | ||
| 204 | // Common write operation patterns | ||
| 205 | writePatterns := []string{ | ||
| 206 | "Publish", | ||
| 207 | "Delete", | ||
| 208 | "Create", | ||
| 209 | "Update", | ||
| 210 | "Insert", | ||
| 211 | "Remove", | ||
| 212 | "Set", | ||
| 213 | "Put", | ||
| 214 | } | ||
| 215 | |||
| 216 | for _, pattern := range writePatterns { | ||
| 217 | if strings.Contains(method, pattern) { | ||
| 218 | return true | ||
| 219 | } | ||
| 220 | } | ||
| 221 | |||
| 222 | return false | ||
| 223 | } | ||
| 224 | |||
| 182 | // shouldSkipAuth checks if a method should bypass authentication. | 225 | // shouldSkipAuth checks if a method should bypass authentication. |
| 183 | func shouldSkipAuth(method string, skipMethods []string) bool { | 226 | func shouldSkipAuth(method string, skipMethods []string) bool { |
| 184 | for _, skip := range skipMethods { | 227 | for _, skip := range skipMethods { |
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: | |||
| 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 npubs (optional, whitelist) | 93 | # Allowed npubs for read operations (optional, whitelist) |
| 94 | # If empty, all valid signatures are accepted | 94 | # If empty, all valid signatures are accepted for reads |
| 95 | # Use npub format only (e.g., npub1...) | 95 | # Use npub format only (e.g., npub1...) |
| 96 | allowed_npubs: [] | 96 | allowed_npubs_read: [] |
| 97 | |||
| 98 | # Allowed npubs for write operations (optional, whitelist) | ||
| 99 | # If empty, all valid signatures are accepted for writes | ||
| 100 | # Use npub format only (e.g., npub1...) | ||
| 101 | allowed_npubs_write: [] | ||
| 102 | |||
| 103 | # Example use cases: | ||
| 104 | # - Public relay: allowed_npubs_write (only some can publish), empty read (everyone can read) | ||
| 105 | # - Private relay: both lists populated (restricted read and write) | ||
| 106 | # - Open relay: both lists empty (everyone can read and write) | ||
| 107 | # | ||
| 97 | # Example: | 108 | # Example: |
| 98 | # allowed_npubs: | 109 | # allowed_npubs_read: |
| 110 | # - npub1a2b3c4d5e6f... | ||
| 111 | # allowed_npubs_write: | ||
| 99 | # - npub1a2b3c4d5e6f... | 112 | # - npub1a2b3c4d5e6f... |
| 100 | 113 | ||
| 101 | # Skip authentication for these methods | 114 | # Skip authentication for these methods |
| @@ -222,7 +235,8 @@ Complex types: | |||
| 222 | 235 | ||
| 223 | ```bash | 236 | ```bash |
| 224 | # Lists (comma-separated, npub format) | 237 | # Lists (comma-separated, npub format) |
| 225 | export MUXSTR_AUTH_ALLOWED_NPUBS="npub1...,npub1...,npub1..." | 238 | export MUXSTR_AUTH_ALLOWED_NPUBS_READ="npub1...,npub1..." |
| 239 | export MUXSTR_AUTH_ALLOWED_NPUBS_WRITE="npub1..." | ||
| 226 | 240 | ||
| 227 | # Durations | 241 | # Durations |
| 228 | export MUXSTR_SERVER_READ_TIMEOUT="30s" | 242 | 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 { | |||
| 39 | 39 | ||
| 40 | // AuthConfig holds authentication configuration. | 40 | // AuthConfig holds authentication configuration. |
| 41 | type AuthConfig struct { | 41 | type AuthConfig struct { |
| 42 | Enabled bool `yaml:"enabled"` | 42 | Enabled bool `yaml:"enabled"` |
| 43 | Required bool `yaml:"required"` | 43 | Required bool `yaml:"required"` |
| 44 | TimestampWindow int64 `yaml:"timestamp_window"` | 44 | TimestampWindow int64 `yaml:"timestamp_window"` |
| 45 | AllowedNpubs []string `yaml:"allowed_npubs"` // npub format only (bech32) - normalized to hex internally | 45 | AllowedNpubsRead []string `yaml:"allowed_npubs_read"` // npub format only (bech32) - normalized to hex internally |
| 46 | SkipMethods []string `yaml:"skip_methods"` | 46 | AllowedNpubsWrite []string `yaml:"allowed_npubs_write"` // npub format only (bech32) - normalized to hex internally |
| 47 | SkipMethods []string `yaml:"skip_methods"` | ||
| 47 | } | 48 | } |
| 48 | 49 | ||
| 49 | // RateLimitConfig holds rate limiting configuration. | 50 | // RateLimitConfig holds rate limiting configuration. |
| @@ -180,12 +181,31 @@ func Load(filename string) (*Config, error) { | |||
| 180 | // Config only accepts npub format (human-readable), which is converted | 181 | // Config only accepts npub format (human-readable), which is converted |
| 181 | // to hex format (computer-readable) for internal use. | 182 | // to hex format (computer-readable) for internal use. |
| 182 | func normalizeNpubs(cfg *Config) error { | 183 | func normalizeNpubs(cfg *Config) error { |
| 183 | if len(cfg.Auth.AllowedNpubs) == 0 { | 184 | var err error |
| 184 | return nil | 185 | |
| 186 | // Normalize read allowlist | ||
| 187 | cfg.Auth.AllowedNpubsRead, err = normalizeNpubList(cfg.Auth.AllowedNpubsRead) | ||
| 188 | if err != nil { | ||
| 189 | return fmt.Errorf("allowed_npubs_read: %w", err) | ||
| 190 | } | ||
| 191 | |||
| 192 | // Normalize write allowlist | ||
| 193 | cfg.Auth.AllowedNpubsWrite, err = normalizeNpubList(cfg.Auth.AllowedNpubsWrite) | ||
| 194 | if err != nil { | ||
| 195 | return fmt.Errorf("allowed_npubs_write: %w", err) | ||
| 196 | } | ||
| 197 | |||
| 198 | return nil | ||
| 199 | } | ||
| 200 | |||
| 201 | // normalizeNpubList converts a list of npubs to hex pubkeys. | ||
| 202 | func normalizeNpubList(npubs []string) ([]string, error) { | ||
| 203 | if len(npubs) == 0 { | ||
| 204 | return nil, nil | ||
| 185 | } | 205 | } |
| 186 | 206 | ||
| 187 | normalized := make([]string, 0, len(cfg.Auth.AllowedNpubs)) | 207 | normalized := make([]string, 0, len(npubs)) |
| 188 | for _, npub := range cfg.Auth.AllowedNpubs { | 208 | for _, npub := range npubs { |
| 189 | // Skip empty strings | 209 | // Skip empty strings |
| 190 | npub = strings.TrimSpace(npub) | 210 | npub = strings.TrimSpace(npub) |
| 191 | if npub == "" { | 211 | if npub == "" { |
| @@ -194,21 +214,20 @@ func normalizeNpubs(cfg *Config) error { | |||
| 194 | 214 | ||
| 195 | // Validate npub format | 215 | // Validate npub format |
| 196 | if !strings.HasPrefix(npub, "npub1") { | 216 | if !strings.HasPrefix(npub, "npub1") { |
| 197 | return fmt.Errorf("invalid npub format %q: must start with 'npub1'", npub) | 217 | return nil, fmt.Errorf("invalid npub format %q: must start with 'npub1'", npub) |
| 198 | } | 218 | } |
| 199 | 219 | ||
| 200 | // Parse npub to get hex pubkey | 220 | // Parse npub to get hex pubkey |
| 201 | key, err := nostr.ParsePublicKey(npub) | 221 | key, err := nostr.ParsePublicKey(npub) |
| 202 | if err != nil { | 222 | if err != nil { |
| 203 | return fmt.Errorf("invalid npub %q: %w", npub, err) | 223 | return nil, fmt.Errorf("invalid npub %q: %w", npub, err) |
| 204 | } | 224 | } |
| 205 | 225 | ||
| 206 | // Get the hex representation for internal use | 226 | // Get the hex representation for internal use |
| 207 | normalized = append(normalized, key.Public()) | 227 | normalized = append(normalized, key.Public()) |
| 208 | } | 228 | } |
| 209 | 229 | ||
| 210 | cfg.Auth.AllowedNpubs = normalized | 230 | return normalized, nil |
| 211 | return nil | ||
| 212 | } | 231 | } |
| 213 | 232 | ||
| 214 | // Validate validates the configuration. | 233 | // Validate validates the configuration. |
| @@ -292,8 +311,11 @@ func applyEnvOverrides(cfg *Config) { | |||
| 292 | cfg.Auth.TimestampWindow = n | 311 | cfg.Auth.TimestampWindow = n |
| 293 | } | 312 | } |
| 294 | } | 313 | } |
| 295 | if val := os.Getenv("MUXSTR_AUTH_ALLOWED_NPUBS"); val != "" { | 314 | if val := os.Getenv("MUXSTR_AUTH_ALLOWED_NPUBS_READ"); val != "" { |
| 296 | cfg.Auth.AllowedNpubs = strings.Split(val, ",") | 315 | cfg.Auth.AllowedNpubsRead = strings.Split(val, ",") |
| 316 | } | ||
| 317 | if val := os.Getenv("MUXSTR_AUTH_ALLOWED_NPUBS_WRITE"); val != "" { | ||
| 318 | cfg.Auth.AllowedNpubsWrite = strings.Split(val, ",") | ||
| 297 | } | 319 | } |
| 298 | 320 | ||
| 299 | // Rate limit | 321 | // 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: | |||
| 260 | 260 | ||
| 261 | auth: | 261 | auth: |
| 262 | enabled: true | 262 | enabled: true |
| 263 | allowed_npubs: | 263 | allowed_npubs_read: |
| 264 | - npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6 | 264 | - npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6 |
| 265 | - npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft | 265 | - npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft |
| 266 | allowed_npubs_write: | ||
| 267 | - npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6 | ||
| 266 | ` | 268 | ` |
| 267 | 269 | ||
| 268 | if _, err := tmpfile.Write([]byte(configData)); err != nil { | 270 | if _, err := tmpfile.Write([]byte(configData)); err != nil { |
| @@ -275,18 +277,32 @@ auth: | |||
| 275 | t.Fatalf("failed to load config: %v", err) | 277 | t.Fatalf("failed to load config: %v", err) |
| 276 | } | 278 | } |
| 277 | 279 | ||
| 278 | // Verify npubs were normalized to hex | 280 | // Verify read npubs were normalized to hex |
| 279 | if len(cfg.Auth.AllowedNpubs) != 2 { | 281 | if len(cfg.Auth.AllowedNpubsRead) != 2 { |
| 280 | t.Errorf("expected 2 allowed npubs, got %d", len(cfg.Auth.AllowedNpubs)) | 282 | t.Errorf("expected 2 allowed npubs for read, got %d", len(cfg.Auth.AllowedNpubsRead)) |
| 283 | } | ||
| 284 | |||
| 285 | // Verify write npubs were normalized to hex | ||
| 286 | if len(cfg.Auth.AllowedNpubsWrite) != 1 { | ||
| 287 | t.Errorf("expected 1 allowed npub for write, got %d", len(cfg.Auth.AllowedNpubsWrite)) | ||
| 281 | } | 288 | } |
| 282 | 289 | ||
| 283 | // Check that they're hex format (64 chars, not npub1...) | 290 | // Check that they're hex format (64 chars, not npub1...) |
| 284 | for i, pubkey := range cfg.Auth.AllowedNpubs { | 291 | for i, pubkey := range cfg.Auth.AllowedNpubsRead { |
| 292 | if len(pubkey) != 64 { | ||
| 293 | t.Errorf("read npub %d: expected 64 hex chars, got %d", i, len(pubkey)) | ||
| 294 | } | ||
| 295 | if len(pubkey) >= 5 && pubkey[:5] == "npub1" { | ||
| 296 | t.Errorf("read npub %d: should be normalized to hex, still in npub format", i) | ||
| 297 | } | ||
| 298 | } | ||
| 299 | |||
| 300 | for i, pubkey := range cfg.Auth.AllowedNpubsWrite { | ||
| 285 | if len(pubkey) != 64 { | 301 | if len(pubkey) != 64 { |
| 286 | t.Errorf("npub %d: expected 64 hex chars, got %d", i, len(pubkey)) | 302 | t.Errorf("write npub %d: expected 64 hex chars, got %d", i, len(pubkey)) |
| 287 | } | 303 | } |
| 288 | if pubkey[:5] == "npub1" { | 304 | if len(pubkey) >= 5 && pubkey[:5] == "npub1" { |
| 289 | t.Errorf("npub %d: should be normalized to hex, still in npub format", i) | 305 | t.Errorf("write npub %d: should be normalized to hex, still in npub format", i) |
| 290 | } | 306 | } |
| 291 | } | 307 | } |
| 292 | 308 | ||
| @@ -294,46 +310,98 @@ auth: | |||
| 294 | expectedHex1 := "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" | 310 | expectedHex1 := "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" |
| 295 | expectedHex2 := "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52" | 311 | expectedHex2 := "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52" |
| 296 | 312 | ||
| 297 | if cfg.Auth.AllowedNpubs[0] != expectedHex1 { | 313 | if cfg.Auth.AllowedNpubsRead[0] != expectedHex1 { |
| 298 | t.Errorf("npub 0: expected %s, got %s", expectedHex1, cfg.Auth.AllowedNpubs[0]) | 314 | t.Errorf("read npub 0: expected %s, got %s", expectedHex1, cfg.Auth.AllowedNpubsRead[0]) |
| 315 | } | ||
| 316 | if cfg.Auth.AllowedNpubsRead[1] != expectedHex2 { | ||
| 317 | t.Errorf("read npub 1: expected %s, got %s", expectedHex2, cfg.Auth.AllowedNpubsRead[1]) | ||
| 299 | } | 318 | } |
| 300 | if cfg.Auth.AllowedNpubs[1] != expectedHex2 { | 319 | if cfg.Auth.AllowedNpubsWrite[0] != expectedHex1 { |
| 301 | t.Errorf("npub 1: expected %s, got %s", expectedHex2, cfg.Auth.AllowedNpubs[1]) | 320 | t.Errorf("write npub 0: expected %s, got %s", expectedHex1, cfg.Auth.AllowedNpubsWrite[0]) |
| 302 | } | 321 | } |
| 303 | } | 322 | } |
| 304 | 323 | ||
| 305 | func TestNpubValidation(t *testing.T) { | 324 | func TestNpubValidation(t *testing.T) { |
| 306 | tmpfile, err := os.CreateTemp("", "config-*.yaml") | 325 | tests := []struct { |
| 307 | if err != nil { | 326 | name string |
| 308 | t.Fatal(err) | 327 | config string |
| 309 | } | 328 | expectError bool |
| 310 | defer os.Remove(tmpfile.Name()) | 329 | errorMsg string |
| 311 | 330 | }{ | |
| 312 | // Invalid: hex format instead of npub | 331 | { |
| 313 | configData := ` | 332 | name: "invalid hex in read list", |
| 333 | config: ` | ||
| 314 | server: | 334 | server: |
| 315 | grpc_addr: ":50051" | 335 | grpc_addr: ":50051" |
| 316 | http_addr: ":8080" | 336 | http_addr: ":8080" |
| 317 | |||
| 318 | database: | 337 | database: |
| 319 | path: "test.db" | 338 | path: "test.db" |
| 320 | |||
| 321 | auth: | 339 | auth: |
| 322 | allowed_npubs: | 340 | allowed_npubs_read: |
| 323 | - 3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d | 341 | - 3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d |
| 324 | ` | 342 | `, |
| 325 | 343 | expectError: true, | |
| 326 | if _, err := tmpfile.Write([]byte(configData)); err != nil { | 344 | errorMsg: "must start with 'npub1'", |
| 327 | t.Fatal(err) | 345 | }, |
| 346 | { | ||
| 347 | name: "invalid hex in write list", | ||
| 348 | config: ` | ||
| 349 | server: | ||
| 350 | grpc_addr: ":50051" | ||
| 351 | http_addr: ":8080" | ||
| 352 | database: | ||
| 353 | path: "test.db" | ||
| 354 | auth: | ||
| 355 | allowed_npubs_write: | ||
| 356 | - 3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d | ||
| 357 | `, | ||
| 358 | expectError: true, | ||
| 359 | errorMsg: "must start with 'npub1'", | ||
| 360 | }, | ||
| 361 | { | ||
| 362 | name: "valid npub lists", | ||
| 363 | config: ` | ||
| 364 | server: | ||
| 365 | grpc_addr: ":50051" | ||
| 366 | http_addr: ":8080" | ||
| 367 | database: | ||
| 368 | path: "test.db" | ||
| 369 | auth: | ||
| 370 | allowed_npubs_read: | ||
| 371 | - npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6 | ||
| 372 | allowed_npubs_write: | ||
| 373 | - npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft | ||
| 374 | `, | ||
| 375 | expectError: false, | ||
| 376 | }, | ||
| 328 | } | 377 | } |
| 329 | tmpfile.Close() | ||
| 330 | 378 | ||
| 331 | _, err = Load(tmpfile.Name()) | 379 | for _, tt := range tests { |
| 332 | if err == nil { | 380 | t.Run(tt.name, func(t *testing.T) { |
| 333 | t.Error("expected error for hex format in allowed_npubs, got nil") | 381 | tmpfile, err := os.CreateTemp("", "config-*.yaml") |
| 334 | } | 382 | if err != nil { |
| 335 | if err != nil && !strings.Contains(err.Error(), "must start with 'npub1'") { | 383 | t.Fatal(err) |
| 336 | t.Errorf("expected 'must start with npub1' error, got: %v", err) | 384 | } |
| 385 | defer os.Remove(tmpfile.Name()) | ||
| 386 | |||
| 387 | if _, err := tmpfile.Write([]byte(tt.config)); err != nil { | ||
| 388 | t.Fatal(err) | ||
| 389 | } | ||
| 390 | tmpfile.Close() | ||
| 391 | |||
| 392 | _, err = Load(tmpfile.Name()) | ||
| 393 | if tt.expectError { | ||
| 394 | if err == nil { | ||
| 395 | t.Error("expected error, got nil") | ||
| 396 | } else if !strings.Contains(err.Error(), tt.errorMsg) { | ||
| 397 | t.Errorf("expected error containing %q, got: %v", tt.errorMsg, err) | ||
| 398 | } | ||
| 399 | } else { | ||
| 400 | if err != nil { | ||
| 401 | t.Errorf("unexpected error: %v", err) | ||
| 402 | } | ||
| 403 | } | ||
| 404 | }) | ||
| 337 | } | 405 | } |
| 338 | } | 406 | } |
| 339 | 407 | ||
