From 976f02519d78eba31e432b8616166e26d6681519 Mon Sep 17 00:00:00 2001 From: Andrew DeChristopher Date: Mon, 23 Feb 2026 22:55:41 -0500 Subject: [PATCH] feat: NIP-05 support --- .env.example | 1 + config.go | 110 ++++++++++++++++------------ main.go | 1 + nip05.go | 198 +++++++++++++++++++++++++++++++++++++++++++++++++++ nostr.json | 5 ++ 5 files changed, 269 insertions(+), 46 deletions(-) create mode 100644 nip05.go create mode 100644 nostr.json diff --git a/.env.example b/.env.example index 5acde60..dce5da8 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ RELAY_BIND_ADDRESS="0.0.0.0" # Can be set to a specific IP4 or IP6 address ("" f DB_ENGINE="badger" # badger, lmdb (lmdb works best with an nvme, otherwise you might have stability issues) LMDB_MAPSIZE=0 # 0 for default (currently ~273GB), or set to a different size in bytes, e.g. 10737418240 for 10GB BLOSSOM_PATH="blossom/" +NIP05_CONFIG_FILE="nostr.json" ## Private Relay Settings PRIVATE_RELAY_NAME="utxo's private relay" diff --git a/config.go b/config.go index af2721f..a227be2 100644 --- a/config.go +++ b/config.go @@ -11,6 +11,7 @@ import ( "time" "github.com/joho/godotenv" + "github.com/nbd-wtf/go-nostr/nip05" "github.com/nbd-wtf/go-nostr/nip19" ) @@ -23,50 +24,52 @@ type S3Config struct { } type Config struct { - OwnerNpub string `json:"owner_npub"` - OwnerPubKey string `json:"owner_pubkey"` - DBEngine string `json:"db_engine"` - LmdbMapSize int64 `json:"lmdb_map_size"` - BlossomPath string `json:"blossom_path"` - RelayURL string `json:"relay_url"` - RelayPort int `json:"relay_port"` - RelayBindAddress string `json:"relay_bind_address"` - RelaySoftware string `json:"relay_software"` - RelayVersion string `json:"relay_version"` - UserAgent string `json:"user_agent"` - PrivateRelayName string `json:"private_relay_name"` - PrivateRelayNpub string `json:"private_relay_npub"` - PrivateRelayDescription string `json:"private_relay_description"` - PrivateRelayIcon string `json:"private_relay_icon"` - ChatRelayName string `json:"chat_relay_name"` - ChatRelayNpub string `json:"chat_relay_npub"` - ChatRelayDescription string `json:"chat_relay_description"` - ChatRelayIcon string `json:"chat_relay_icon"` - OutboxRelayName string `json:"outbox_relay_name"` - OutboxRelayNpub string `json:"outbox_relay_npub"` - OutboxRelayDescription string `json:"outbox_relay_description"` - OutboxRelayIcon string `json:"outbox_relay_icon"` - InboxRelayName string `json:"inbox_relay_name"` - InboxRelayNpub string `json:"inbox_relay_npub"` - InboxRelayDescription string `json:"inbox_relay_description"` - InboxRelayIcon string `json:"inbox_relay_icon"` - InboxPullIntervalSeconds int `json:"inbox_pull_interval_seconds"` - ImportStartDate string `json:"import_start_date"` - ImportOwnerNotesFetchTimeoutSeconds int `json:"import_owned_notes_fetch_timeout_seconds"` - ImportTaggedNotesFetchTimeoutSeconds int `json:"import_tagged_fetch_timeout_seconds"` - ImportSeedRelays []string `json:"import_seed_relays"` - BackupProvider string `json:"backup_provider"` - BackupIntervalHours int `json:"backup_interval_hours"` - WotDepth int `json:"wot_depth"` - WotMinimumFollowers int `json:"wot_minimum_followers"` - WotFetchTimeoutSeconds int `json:"wot_fetch_timeout_seconds"` - WotRefreshInterval time.Duration `json:"wot_refresh_interval"` - WhitelistedPubKeys map[string]struct{} `json:"whitelisted_pubkeys"` - BlacklistedPubKeys map[string]struct{} `json:"blacklisted_pubkeys"` - LogLevel string `json:"log_level"` - BlastrRelays []string `json:"blastr_relays"` - BlastrTimeoutSeconds int `json:"blastr_timeout_seconds"` - S3Config *S3Config `json:"s3_config"` + OwnerNpub string `json:"owner_npub"` + OwnerPubKey string `json:"owner_pubkey"` + DBEngine string `json:"db_engine"` + LmdbMapSize int64 `json:"lmdb_map_size"` + BlossomPath string `json:"blossom_path"` + RelayURL string `json:"relay_url"` + RelayPort int `json:"relay_port"` + RelayBindAddress string `json:"relay_bind_address"` + RelaySoftware string `json:"relay_software"` + RelayVersion string `json:"relay_version"` + UserAgent string `json:"user_agent"` + PrivateRelayName string `json:"private_relay_name"` + PrivateRelayNpub string `json:"private_relay_npub"` + PrivateRelayDescription string `json:"private_relay_description"` + PrivateRelayIcon string `json:"private_relay_icon"` + ChatRelayName string `json:"chat_relay_name"` + ChatRelayNpub string `json:"chat_relay_npub"` + ChatRelayDescription string `json:"chat_relay_description"` + ChatRelayIcon string `json:"chat_relay_icon"` + OutboxRelayName string `json:"outbox_relay_name"` + OutboxRelayNpub string `json:"outbox_relay_npub"` + OutboxRelayDescription string `json:"outbox_relay_description"` + OutboxRelayIcon string `json:"outbox_relay_icon"` + InboxRelayName string `json:"inbox_relay_name"` + InboxRelayNpub string `json:"inbox_relay_npub"` + InboxRelayDescription string `json:"inbox_relay_description"` + InboxRelayIcon string `json:"inbox_relay_icon"` + InboxPullIntervalSeconds int `json:"inbox_pull_interval_seconds"` + ImportStartDate string `json:"import_start_date"` + ImportOwnerNotesFetchTimeoutSeconds int `json:"import_owned_notes_fetch_timeout_seconds"` + ImportTaggedNotesFetchTimeoutSeconds int `json:"import_tagged_fetch_timeout_seconds"` + ImportSeedRelays []string `json:"import_seed_relays"` + BackupProvider string `json:"backup_provider"` + BackupIntervalHours int `json:"backup_interval_hours"` + WotDepth int `json:"wot_depth"` + WotMinimumFollowers int `json:"wot_minimum_followers"` + WotFetchTimeoutSeconds int `json:"wot_fetch_timeout_seconds"` + WotRefreshInterval time.Duration `json:"wot_refresh_interval"` + WhitelistedPubKeys map[string]struct{} `json:"whitelisted_pubkeys"` + BlacklistedPubKeys map[string]struct{} `json:"blacklisted_pubkeys"` + NIP05ConfigFile string `json:"nip05_config_file"` + NIP05 *nip05.WellKnownResponse `json:"nip05_config"` + LogLevel string `json:"log_level"` + BlastrRelays []string `json:"blastr_relays"` + BlastrTimeoutSeconds int `json:"blastr_timeout_seconds"` + S3Config *S3Config `json:"s3_config"` } const relaySoftware = "https://github.com/bitvora/haven" @@ -113,6 +116,7 @@ func loadConfig() Config { WotMinimumFollowers: getEnvInt("WOT_MINIMUM_FOLLOWERS", 0), WotFetchTimeoutSeconds: getEnvInt("WOT_FETCH_TIMEOUT_SECONDS", 30), WotRefreshInterval: getEnvDuration("WOT_REFRESH_INTERVAL", 24*time.Hour), + NIP05ConfigFile: getEnvString("NIP05_CONFIG_FILE", "nostr.json"), WhitelistedPubKeys: getNpubsFromFile(getEnvString("WHITELISTED_NPUBS_FILE", "")), BlacklistedPubKeys: getNpubsFromFile(getEnvString("BLACKLISTED_NPUBS_FILE", "")), LogLevel: getEnvString("HAVEN_LOG_LEVEL", "INFO"), @@ -124,8 +128,17 @@ func loadConfig() Config { // Relay owner is always whitelisted cfg.WhitelistedPubKeys[cfg.OwnerPubKey] = struct{}{} - return cfg + // Read, parse, and validate NIP-05 config + cfg.NIP05 = getNIP05FromFile(cfg.NIP05ConfigFile) + + // Ensure that all hosted NIP-05 identifiers are whitelisted + if cfg.NIP05 != nil && cfg.NIP05.Names != nil { + for _, pubkey := range cfg.NIP05.Names { + cfg.WhitelistedPubKeys[pubkey] = struct{}{} + } + } + return cfg } func getVersion() string { @@ -153,12 +166,17 @@ func getS3Config() *S3Config { } func getRelayListFromFile(filePath string) []string { + var relayList []string + if filePath == "" { + // No pubKeys file, only owner will be whitelisted" + return relayList + } + file, err := os.ReadFile(filePath) if err != nil { log.Fatalf("Failed to read file: %s", err) } - var relayList []string if err := json.Unmarshal(file, &relayList); err != nil { log.Fatalf("Failed to parse JSON: %s", err) } diff --git a/main.go b/main.go index 80b5c2d..f16bacd 100644 --- a/main.go +++ b/main.go @@ -95,6 +95,7 @@ func main() { }() http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("templates/static")))) + http.HandleFunc("/.well-known/nostr.json", nip05Handler(config.NIP05)) http.HandleFunc("/", dynamicRelayHandler) addr := fmt.Sprintf("%s:%d", config.RelayBindAddress, config.RelayPort) diff --git a/nip05.go b/nip05.go new file mode 100644 index 0000000..dd3c33a --- /dev/null +++ b/nip05.go @@ -0,0 +1,198 @@ +package main + +import ( + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "os" + "regexp" + "strings" + + "github.com/nbd-wtf/go-nostr/nip05" +) + +const MaxNIP05IdentifierLength = 253 + +// NIP-05 local-part regex +var localPartRegex = regexp.MustCompile("^[a-zA-Z0-9-_.]+$") + +func nip05Handler(cfg *nip05.WellKnownResponse) func(w http.ResponseWriter, r *http.Request) { + if cfg == nil || cfg.Names == nil || len(cfg.Names) == 0 { + slog.Info("⚠️ NIP-05 handler disabled: no NIP-05 config found with valid identifiers") + return http.NotFoundHandler().ServeHTTP + } + + slog.Info("✅ NIP-05 handler enabled") + return func(w http.ResponseWriter, r *http.Request) { + // Extract and normalize the 'name' parameter + name := strings.ToLower(r.URL.Query().Get("name")) + + var response nip05.WellKnownResponse + + // optional: filter response by name + if name != "" { + // validate name parameter + if err := validateName(name); err != nil { + http.Error(w, "invalid name", http.StatusBadRequest) + slog.Error("🚫 provided NIP-05 name invalid", "name", name, "error", err) + return + } + + pubkey, ok := cfg.Names[name] + if !ok { + // return 404 if name not found + w.WriteHeader(http.StatusNotFound) + slog.Error("🚫 provided NIP-05 name not found", "name", name) + return + } + + // filter Names by name + response.Names = map[string]string{name: pubkey} + if cfg.Relays[pubkey] != nil { + // filter Relays by name + response.Relays = map[string][]string{ + pubkey: cfg.Relays[pubkey], + } + } + + response.NIP46 = cfg.NIP46 + } else { + // fill response with the entire NIP-05 config + response = *cfg + } + + // Set required headers for CORS + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Content-Type", "application/json") + + // Encode response + err := json.NewEncoder(w).Encode(response) + if err != nil { + slog.Error("🚫 failed to write NIP-05 response", "name", name, "error", err) + return + } + + slog.Debug("NIP-05 fetched", "name", name) + } +} + +func getNIP05FromFile(filePath string) *nip05.WellKnownResponse { + // open the NIP-05 config file (default: nostr.json) + nip05ConfigFile, err := os.Open(filePath) + if err != nil { + slog.Error("🚫 failed to open NIP-05 config file:", "error", err) + return nil + } + defer nip05ConfigFile.Close() + + // Parse and validate NIP-05 config file + nip05Config, err := parseAndValidateNIP05(nip05ConfigFile) + if err != nil { + slog.Error("🚫 failed to parse and validate NIP-05 config file", "error", err) + return nil + } + + return nip05Config +} + +// parseAndValidateNIP05 reads JSON from an io.Reader and validates NIP-05 data +func parseAndValidateNIP05(r io.Reader) (*nip05.WellKnownResponse, error) { + var resp nip05.WellKnownResponse + + // Decode the JSON + decoder := json.NewDecoder(r) + if err := decoder.Decode(&resp); err != nil { + return nil, fmt.Errorf("failed to decode JSON: %w", err) + } + + // Validate 'names' map exists + if resp.Names == nil || len(resp.Names) == 0 { + return nil, errors.New("invalid NIP-05: 'names' map is missing or empty") + } + + // Validate 'names' public keys + for name, pubkey := range resp.Names { + if err := validateName(name); err != nil { + return nil, fmt.Errorf("invalid name '%s': %w", name, err) + } + if err := validatePubkey(pubkey); err != nil { + return nil, fmt.Errorf("invalid public key for name '%s': %w", name, err) + } + } + + // Validate 'relays' map (optional) + if resp.Relays != nil { + for pubkey, relays := range resp.Relays { + if err := validatePubkey(pubkey); err != nil { + return nil, fmt.Errorf("invalid public key in 'relays' mapping: %w", err) + } + if err := validateRelayURLs(relays); err != nil { + return nil, fmt.Errorf("invalid relay URL for pubkey '%s': %w", pubkey, err) + } + } + } + + // Validate 'nip46' map (optional) + if resp.NIP46 != nil { + for pubkey, relays := range resp.NIP46 { + if err := validatePubkey(pubkey); err != nil { + return nil, fmt.Errorf("invalid public key in 'nip46' mapping: %w", err) + } + if err := validateRelayURLs(relays); err != nil { + return nil, fmt.Errorf("invalid nip46 relay URL for pubkey '%s': %w", pubkey, err) + } + } + } + + return &resp, nil +} + +// validateName validates a NIP-05 name +func validateName(name string) error { + if !localPartRegex.MatchString(name) { + return errors.New("name contains invalid characters") + } + + if len(fmt.Sprintf("")) > MaxNIP05IdentifierLength { + return errors.New("name too long") + } + + return nil +} + +// validatePubkey ensures the pubkey is a 64-character hex string +func validatePubkey(pubkey string) error { + if len(pubkey) != 64 { + return fmt.Errorf("must be exactly 64 characters, got %d", len(pubkey)) + } + _, err := hex.DecodeString(pubkey) + if err != nil { + return errors.New("must contain only valid hex characters") + } + return nil +} + +// validateRelayURLs ensures URLs are valid and use websocket protocols +func validateRelayURLs(urls []string) error { + for _, rawURL := range urls { + parsedURL, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("failed to parse URL '%s': %w", rawURL, err) + } + + scheme := strings.ToLower(parsedURL.Scheme) + if scheme != "ws" && scheme != "wss" { + return fmt.Errorf("URL '%s' must start with ws:// or wss://", rawURL) + } + + if parsedURL.Host == "" { + return fmt.Errorf("URL '%s' is missing a host", rawURL) + } + } + return nil +} diff --git a/nostr.json b/nostr.json new file mode 100644 index 0000000..f1b798e --- /dev/null +++ b/nostr.json @@ -0,0 +1,5 @@ +{ + "names": {}, + "relays": {}, + "nip46": {} +} \ No newline at end of file