diff --git a/.env.example b/.env.example index f9a4232..31c7f3c 100644 --- a/.env.example +++ b/.env.example @@ -103,5 +103,21 @@ WOT_REFRESH_INTERVAL="24h" WHITELISTED_NPUBS_FILE="whitelisted_npubs.json" BLACKLISTED_NPUBS_FILE="blacklisted_npubs.json" +# Optional: whitelist by Namecoin (.bit) names. Each entry in the JSON +# file is a .bit identifier (e.g. "me@me.bit", "d/me", "id/alice"). +# At startup, HAVEN resolves each name to its underlying npub via +# public Namecoin ElectrumX servers and merges them into the whitelist +# alongside WHITELISTED_NPUBS_FILE. Spec: +# https://github.com/nostr-protocol/nips/pull/2349 +# WHITELISTED_NAMECOIN_NAMES_FILE="whitelisted_namecoin_names.json" + +# Optional: whitelist by Namecoin (.bit) names. Each entry in the JSON +# file is a .bit identifier (e.g. "me@me.bit", "d/me", "id/alice"). +# At startup, HAVEN resolves each name to its underlying npub via +# public Namecoin ElectrumX servers and merges them into the whitelist +# alongside WHITELISTED_NPUBS_FILE. Spec: +# https://github.com/nostr-protocol/nips/pull/2349 +# WHITELISTED_NAMECOIN_NAMES_FILE="whitelisted_namecoin_names.json" + ## LOGGING HAVEN_LOG_LEVEL="INFO" # DEBUG, INFO, WARNING or ERROR \ No newline at end of file diff --git a/README.md b/README.md index b79ad63..a994b5a 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,18 @@ interaction with your relay. See the [Access Control Documentation](docs/access-control.md) for more details on how to set up whitelists and blacklists. +#### Whitelisting by Namecoin (`.bit`) name + +As an additive opt-in alternative, you can whitelist users by their Namecoin (`.bit`) name instead of (or in +addition to) their npub. Point `WHITELISTED_NAMECOIN_NAMES_FILE` at a JSON file containing `.bit` identifiers +(e.g. `me@me.bit`, `d/example`, `id/alice`). At startup, Haven resolves each name to its underlying pubkey via +public Namecoin ElectrumX servers and merges the result into the whitelist alongside `WHITELISTED_NPUBS_FILE`. + +Resolution is best-effort: if a name can't be resolved (no Namecoin record, ElectrumX unreachable, etc.) Haven +logs a warning and keeps starting. If both `WHITELISTED_NPUBS_FILE` and `WHITELISTED_NAMECOIN_NAMES_FILE` are +set, both contribute to the same in-memory whitelist. See `whitelisted_namecoin_names.example.json` for the +file shape. Spec reference: [NIP-05 Namecoin extension](https://github.com/nostr-protocol/nips/pull/2349). + ### 5. Run on System Startup #### Linux – Create a Systemd Service diff --git a/config.go b/config.go index 0a05c9f..3c3a71a 100644 --- a/config.go +++ b/config.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/barrydeen/haven/pkg/nip05namecoin" "github.com/joho/godotenv" "github.com/nbd-wtf/go-nostr/nip19" ) @@ -62,6 +63,7 @@ type Config struct { WotFetchTimeoutSeconds int `json:"wot_fetch_timeout_seconds"` WotRefreshInterval time.Duration `json:"wot_refresh_interval"` WhitelistedPubKeys map[string]struct{} `json:"whitelisted_pubkeys"` + WhitelistedNamecoinNames map[string]struct{} `json:"whitelisted_namecoin_names"` BlacklistedPubKeys map[string]struct{} `json:"blacklisted_pubkeys"` LogLevel string `json:"log_level"` BlastrRelays []string `json:"blastr_relays"` @@ -114,6 +116,7 @@ func loadConfig() Config { WotFetchTimeoutSeconds: getEnvInt("WOT_FETCH_TIMEOUT_SECONDS", 30), WotRefreshInterval: getEnvDuration("WOT_REFRESH_INTERVAL", 24*time.Hour), WhitelistedPubKeys: getNpubsFromFile(getEnvString("WHITELISTED_NPUBS_FILE", "")), + WhitelistedNamecoinNames: getNamesFromFile(getEnvString("WHITELISTED_NAMECOIN_NAMES_FILE", "")), BlacklistedPubKeys: getNpubsFromFile(getEnvString("BLACKLISTED_NPUBS_FILE", "")), LogLevel: getEnvString("HAVEN_LOG_LEVEL", "INFO"), BlastrRelays: getRelayListFromFile(getEnv("BLASTR_RELAYS_FILE")), @@ -124,6 +127,16 @@ func loadConfig() Config { // Relay owner is always whitelisted cfg.WhitelistedPubKeys[cfg.OwnerPubKey] = struct{}{} + // Resolve any configured Namecoin (.bit) names into pubkeys and + // merge them into the existing whitelist. Best-effort: failures + // log a warning but do not abort startup, so an operator can bring + // their pod online before the resolver succeeds. + nip05namecoin.ResolveNamesToPubkeys( + cfg.WhitelistedPubKeys, + cfg.WhitelistedNamecoinNames, + nip05namecoin.ResolveOptions{Logger: log.Default()}, + ) + return cfg } @@ -173,6 +186,19 @@ func getRelayListFromFile(filePath string) []string { return relayList } +// getNamesFromFile loads a JSON array of Namecoin (.bit) identifiers +// from `filePath` and returns them as a set. Identifiers are kept as +// strings (e.g. "me@me.bit", "d/me") and resolved later at startup. +// Mirrors getNpubsFromFile: parse failures abort startup, since the +// operator clearly intended to load a whitelist file. +func getNamesFromFile(filePath string) map[string]struct{} { + names, err := nip05namecoin.LoadNamesFile(filePath) + if err != nil { + log.Fatalf("Failed to load Namecoin names file: %s", err) + } + return names +} + func getNpubsFromFile(filePath string) map[string]struct{} { pubKeys := map[string]struct{}{} if filePath == "" { diff --git a/pkg/nip05namecoin/electrumx.go b/pkg/nip05namecoin/electrumx.go new file mode 100644 index 0000000..5f89d90 --- /dev/null +++ b/pkg/nip05namecoin/electrumx.go @@ -0,0 +1,357 @@ +package nip05namecoin + +import ( + "bufio" + "context" + "crypto/tls" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net" + "strconv" + "strings" + "sync/atomic" + "time" +) + +// Protocol version negotiated with the server. +const electrumProtocolVersion = "1.4" + +// NameExpireDepth is the number of blocks after which a Namecoin name +// expires if not re-registered (≈ 250 days at 10 min/block). Sourced +// from Namecoin's chainparams.cpp → consensus.nNameExpirationDepth. +const NameExpireDepth = 36000 + +// NameShowResult is the structured outcome of a name_show lookup. +type NameShowResult struct { + Name string + Value string + TxID string + Height int + ExpiresIn int // blocks until expiry; 0 if unknown +} + +// Errors we surface to callers. NameNotFound and NameExpired are +// "definitive" (blockchain said so) — no point retrying other servers. +// ServersUnreachable is returned when every candidate server failed +// with a transport-level error. +var ( + ErrNameNotFound = errors.New("nip05namecoin: name not found on Namecoin blockchain") + ErrNameExpired = errors.New("nip05namecoin: namecoin name has expired") + ErrServersUnreachable = errors.New("nip05namecoin: all ElectrumX servers unreachable") +) + +// rpcConn is the transport-agnostic RPC connection abstraction. HAVEN +// only ships the TCP/TCP+TLS transport — WSS is intentionally dropped +// from the server-only context. +type rpcConn interface { + writeRequest(ctx context.Context, method string, params []any, id int64) error + readResponse(ctx context.Context) (string, error) + close() error +} + +// ElectrumClient is a minimal, query-only Namecoin ElectrumX client. +// It opens a short-lived socket per request, which is plenty for the +// startup whitelist resolution that this package supports. +type ElectrumClient struct { + ConnectTimeout time.Duration + ReadTimeout time.Duration + requestID atomic.Int64 +} + +// NewElectrumClient returns a client with sensible defaults. +func NewElectrumClient() *ElectrumClient { + return &ElectrumClient{ + ConnectTimeout: 10 * time.Second, + ReadTimeout: 15 * time.Second, + } +} + +// NameShow queries a single server and returns the current value for +// `identifier` (e.g. "d/example"). Returns nil + ErrNameNotFound when +// the name is provably absent, and a generic error for transport +// failures. +func (c *ElectrumClient) NameShow(ctx context.Context, identifier string, server ElectrumxServer) (*NameShowResult, error) { + conn, err := c.dial(ctx, server) + if err != nil { + return nil, err + } + defer conn.close() + + reqCtx, cancel := context.WithTimeout(ctx, c.ReadTimeout) + defer cancel() + + next := func() int64 { return c.requestID.Add(1) } + + // 1. Negotiate protocol version. The response is consumed and + // discarded — we only care that the socket is alive. + if err := conn.writeRequest(reqCtx, "server.version", []any{"haven-namecoin/0.1", electrumProtocolVersion}, next()); err != nil { + return nil, err + } + if _, err := conn.readResponse(reqCtx); err != nil { + return nil, fmt.Errorf("nip05namecoin: read version response: %w", err) + } + + // 2. Compute the name-index scripthash. + script := buildNameIndexScript([]byte(identifier)) + scriptHash := electrumScriptHash(script) + + // 3. Fetch transaction history for that scripthash. + if err := conn.writeRequest(reqCtx, "blockchain.scripthash.get_history", []any{scriptHash}, next()); err != nil { + return nil, err + } + histLine, err := conn.readResponse(reqCtx) + if err != nil { + return nil, fmt.Errorf("nip05namecoin: read history response: %w", err) + } + entries, err := parseHistoryResponse(histLine) + if err != nil { + return nil, err + } + if len(entries) == 0 { + return nil, ErrNameNotFound + } + + // Most recent transaction = last entry. + latest := entries[len(entries)-1] + + // 4. Fetch the verbose transaction. + if err := conn.writeRequest(reqCtx, "blockchain.transaction.get", []any{latest.TxHash, true}, next()); err != nil { + return nil, err + } + txLine, err := conn.readResponse(reqCtx) + if err != nil { + return nil, fmt.Errorf("nip05namecoin: read transaction response: %w", err) + } + + // 5. Get the current block height so we can compute expiry. + if err := conn.writeRequest(reqCtx, "blockchain.headers.subscribe", []any{}, next()); err != nil { + return nil, err + } + headerLine, _ := conn.readResponse(reqCtx) + currentHeight := parseBlockHeight(headerLine) + + if currentHeight > 0 && latest.Height > 0 { + if currentHeight-latest.Height >= NameExpireDepth { + return nil, ErrNameExpired + } + } + + result, err := parseNameFromTransaction(identifier, latest.TxHash, latest.Height, txLine) + if err != nil { + return nil, err + } + if result != nil && currentHeight > 0 && latest.Height > 0 { + result.ExpiresIn = NameExpireDepth - (currentHeight - latest.Height) + } + return result, nil +} + +// NameShowWithFallback tries each server in order until one returns a +// result. Definitive errors (NameNotFound, NameExpired) are propagated +// immediately; transport errors are swallowed and the next server is +// tried. +func (c *ElectrumClient) NameShowWithFallback(ctx context.Context, identifier string, servers []ElectrumxServer) (*NameShowResult, error) { + var lastErr error + for _, srv := range servers { + result, err := c.NameShow(ctx, identifier, srv) + if err == nil { + return result, nil + } + if errors.Is(err, ErrNameNotFound) || errors.Is(err, ErrNameExpired) { + return nil, err + } + lastErr = err + } + if lastErr == nil { + lastErr = ErrServersUnreachable + } + return nil, fmt.Errorf("%w: last error: %v", ErrServersUnreachable, lastErr) +} + +// dial picks the right transport implementation based on the server +// configuration and returns a ready-to-use rpcConn. HAVEN supports +// TCP and TCP+TLS only. +func (c *ElectrumClient) dial(ctx context.Context, server ElectrumxServer) (rpcConn, error) { + switch effectiveTransport(server) { + case TransportTCPTLS, TransportTCP: + return c.dialTCP(ctx, server) + default: + return nil, fmt.Errorf("nip05namecoin: unsupported transport %d (HAVEN ships TCP/TCP+TLS only)", server.Transport) + } +} + +// dialTCP opens a raw TCP connection to the server, upgrading to TLS +// when the effective transport is TCPTLS. Honours both context +// cancellation and our connect timeout, whichever fires first. +func (c *ElectrumClient) dialTCP(ctx context.Context, server ElectrumxServer) (rpcConn, error) { + dialer := &net.Dialer{Timeout: c.ConnectTimeout} + address := net.JoinHostPort(server.Host, strconv.Itoa(server.Port)) + + raw, err := dialer.DialContext(ctx, "tcp", address) + if err != nil { + return nil, fmt.Errorf("nip05namecoin: dial %s: %w", address, err) + } + + var nc net.Conn = raw + if effectiveTransport(server) == TransportTCPTLS { + cfg := tlsConfigFor(server) + tlsConn := tls.Client(raw, cfg) + + if deadline, ok := ctx.Deadline(); ok { + _ = tlsConn.SetDeadline(deadline) + } else { + _ = tlsConn.SetDeadline(time.Now().Add(c.ConnectTimeout)) + } + if err := tlsConn.HandshakeContext(ctx); err != nil { + raw.Close() + return nil, fmt.Errorf("nip05namecoin: TLS handshake with %s: %w", address, err) + } + _ = tlsConn.SetDeadline(time.Time{}) + nc = tlsConn + } + + _ = nc.SetDeadline(time.Now().Add(c.ReadTimeout)) + return &tcpRPCConn{conn: nc, reader: bufio.NewReader(nc)}, nil +} + +// tcpRPCConn is the newline-delimited JSON-RPC transport over raw +// TCP or TCP+TLS. +type tcpRPCConn struct { + conn net.Conn + reader *bufio.Reader +} + +func (t *tcpRPCConn) writeRequest(ctx context.Context, method string, params []any, id int64) error { + payload := map[string]any{ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + } + encoded, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("nip05namecoin: marshal rpc request: %w", err) + } + encoded = append(encoded, '\n') + if deadline, ok := ctx.Deadline(); ok { + _ = t.conn.SetWriteDeadline(deadline) + } + if _, err := t.conn.Write(encoded); err != nil { + return fmt.Errorf("nip05namecoin: write rpc request: %w", err) + } + return nil +} + +func (t *tcpRPCConn) readResponse(ctx context.Context) (string, error) { + if deadline, ok := ctx.Deadline(); ok { + _ = t.conn.SetReadDeadline(deadline) + } + line, err := t.reader.ReadString('\n') + if err != nil { + return "", err + } + return line, nil +} + +func (t *tcpRPCConn) close() error { + return t.conn.Close() +} + +// historyEntry is one row of `blockchain.scripthash.get_history`. +type historyEntry struct { + TxHash string + Height int +} + +// parseHistoryResponse extracts (tx_hash, height) pairs from a +// get_history response. An error response (non-null `error` field) +// yields an empty slice so callers can treat it as "no data". +func parseHistoryResponse(raw string) ([]historyEntry, error) { + var envelope struct { + Result []struct { + TxHash string `json:"tx_hash"` + Height int `json:"height"` + } `json:"result"` + Error json.RawMessage `json:"error"` + } + if err := json.Unmarshal([]byte(raw), &envelope); err != nil { + return nil, fmt.Errorf("nip05namecoin: parse history response: %w", err) + } + if len(envelope.Error) > 0 && !isJSONNull(envelope.Error) { + return nil, nil + } + out := make([]historyEntry, 0, len(envelope.Result)) + for _, e := range envelope.Result { + out = append(out, historyEntry{TxHash: e.TxHash, Height: e.Height}) + } + return out, nil +} + +// parseBlockHeight extracts `result.height` from a +// `blockchain.headers.subscribe` response, or returns 0 on any error. +func parseBlockHeight(raw string) int { + if raw == "" { + return 0 + } + var envelope struct { + Result struct { + Height int `json:"height"` + } `json:"result"` + } + if err := json.Unmarshal([]byte(raw), &envelope); err != nil { + return 0 + } + return envelope.Result.Height +} + +// parseNameFromTransaction walks the verbose transaction's vouts +// looking for a NAME_UPDATE output that matches `identifier`. +// Returns (nil, nil) if no matching output exists. +func parseNameFromTransaction(identifier, txHash string, height int, raw string) (*NameShowResult, error) { + var envelope struct { + Result struct { + Vout []struct { + ScriptPubKey struct { + Hex string `json:"hex"` + } `json:"scriptPubKey"` + } `json:"vout"` + } `json:"result"` + Error json.RawMessage `json:"error"` + } + if err := json.Unmarshal([]byte(raw), &envelope); err != nil { + return nil, fmt.Errorf("nip05namecoin: parse transaction response: %w", err) + } + if len(envelope.Error) > 0 && !isJSONNull(envelope.Error) { + return nil, nil + } + for _, vout := range envelope.Result.Vout { + hexScript := vout.ScriptPubKey.Hex + if !strings.HasPrefix(hexScript, "53") { + continue + } + scriptBytes, err := hex.DecodeString(hexScript) + if err != nil { + continue + } + name, value, err := parseNameScript(scriptBytes) + if err != nil { + continue + } + if name == identifier { + return &NameShowResult{ + Name: name, + Value: value, + TxID: txHash, + Height: height, + }, nil + } + } + return nil, nil +} + +func isJSONNull(raw json.RawMessage) bool { + s := strings.TrimSpace(string(raw)) + return s == "" || s == "null" +} diff --git a/pkg/nip05namecoin/import.go b/pkg/nip05namecoin/import.go new file mode 100644 index 0000000..97d815d --- /dev/null +++ b/pkg/nip05namecoin/import.go @@ -0,0 +1,336 @@ +package nip05namecoin + +import ( + "encoding/json" + "strings" +) + +// DefaultMaxImportDepth is the minimum recursion depth that ifa-0001 +// §"import" requires implementations to support. We default to four; +// callers may pass a higher budget but must not pass less without +// understanding the consequences (chains beyond this point are +// silently truncated). +const DefaultMaxImportDepth = 4 + +// nameValueLookup is the synchronous lookup callback used by +// expandImports. It returns the raw JSON value string for a Namecoin +// name, or the empty string if the name does not exist / could not +// be fetched / yielded malformed JSON. The contract is lenient: a +// failed lookup is treated as if the imported value were the empty +// object {} so transient ElectrumX hiccups do not nuke an otherwise +// resolvable record. +// +// The callback is invoked at most once per (name, selector) pair +// within a single top-level expansion. +type nameValueLookup func(namecoinName string) string + +// expandImports walks the ifa-0001 §"import" chain rooted at `root` +// and returns a single merged object with no "import" key. The +// importing object's items always win; null values in the importer +// suppress the corresponding imported value (semantic deletion). +// +// Behaviour rules — see SHARED-SPEC.md (and the Kotlin reference at +// quartz/.../NamecoinImportResolver.kt) for the canonical write-up: +// +// 1. No "import" key → return root unchanged (zero extra I/O). +// 2. Four shorthand forms for the import value, all canonicalised +// to [][2]string{{name, selector}, ...}: +// • "d/foo" → [["d/foo", ""]] +// • ["d/foo"] → [["d/foo", ""]] +// • ["d/foo", "selector"] → [["d/foo", "selector"]] +// • [["d/foo", "sel"], ...] → canonical, as-is +// Malformed import values (number, bool, object, wrong shape) +// are silently dropped; the importer's own fields still apply. +// 3. Selector walk on the imported value follows ifa-0001 §"map": +// exact label > "*" wildcard > "" default, walked right-to-left +// (DNS-rightmost label first). +// 4. Merge is recursive with importer-wins semantics. JSON null in +// the importer suppresses the imported value for that key. +// Object values merge recursively; non-object values replace +// wholesale. +// 5. Recursion budget defaults to DefaultMaxImportDepth (4). When +// exhausted, the partial merge still applies — the importer's +// own fields are not lost. +// 6. Cycle protection: visited (name|selector) pairs are tracked +// within one top-level call. +// 7. Lenient I/O: lookup returning "" (or malformed JSON) → {}. +// 8. The "import" key is stripped from the returned object. +// +// The root is mutated only via copy-on-write: callers may continue +// to use their original map after this returns. +func expandImports(root map[string]any, lookup nameValueLookup, maxDepth int) map[string]any { + if maxDepth <= 0 { + maxDepth = DefaultMaxImportDepth + } + return expandRecursive(root, lookup, maxDepth, map[string]struct{}{}) +} + +func expandRecursive(obj map[string]any, lookup nameValueLookup, budget int, visited map[string]struct{}) map[string]any { + rawImport, has := obj["import"] + if !has { + return obj + } + ops, ok := parseImportItem(rawImport) + if !ok { + // Malformed import: drop the key, keep the rest. + return removeImportKey(obj) + } + if len(ops) == 0 || budget <= 0 { + return removeImportKey(obj) + } + + // Walk imports left-to-right. The spec is silent on multi-import + // precedence; we follow the Kotlin reference's convention that + // later imports override earlier ones in the same array. The + // whole accumulator still loses to the importing object. + accumulator := map[string]any{} + for _, op := range ops { + key := op.name + "|" + op.selector + if _, seen := visited[key]; seen { + continue + } + visited[key] = struct{}{} + func() { + defer delete(visited, key) + rawValue := lookup(op.name) + if rawValue == "" { + return + } + importedRoot, ok := tryParseObject(rawValue) + if !ok { + return + } + selectorView, ok := applySelector(importedRoot, op.selector) + if !ok { + return + } + expanded := expandRecursive(selectorView, lookup, budget-1, visited) + accumulator = mergeImporterWins(expanded, accumulator) + }() + } + + // Finally merge the importing object (sans "import") on top. + withoutImport := removeImportKey(obj) + return mergeImporterWins(withoutImport, accumulator) +} + +// mergeImporterWins returns a fresh map where every key in importer +// stays as-is (including JSON null, which acts as semantic delete on +// the imported counterpart) and keys present only in imported are +// added. Nested objects are merged recursively with the same rules. +func mergeImporterWins(importer, imported map[string]any) map[string]any { + if len(imported) == 0 { + // Still return a shallow copy so callers can mutate freely. + out := make(map[string]any, len(importer)) + for k, v := range importer { + out[k] = v + } + return out + } + if len(importer) == 0 { + out := make(map[string]any, len(imported)) + for k, v := range imported { + out[k] = v + } + return out + } + out := make(map[string]any, len(importer)+len(imported)) + for k, v := range imported { + out[k] = v + } + for k, v := range importer { + // If both sides are objects, recurse — except when importer + // value is JSON null, which is a suppression marker and must + // survive unchanged. + if v == nil { + out[k] = nil + continue + } + if impObj, ok := v.(map[string]any); ok { + if otherObj, ok := out[k].(map[string]any); ok { + out[k] = mergeImporterWins(impObj, otherObj) + continue + } + } + out[k] = v + } + return out +} + +// applySelector walks the imported value's `map` tree to the node +// addressed by selector (DNS-dotted, e.g. "relay" or "a.b.c"). Empty +// selector or no `map` returns root unchanged. +// +// Resolution rules per ifa-0001 §"map": +// - Exact label match wins. +// - "*" wildcard matches any single label. +// - "" empty key is the default when no other match applies. +// - A non-object child terminates the walk with (nil, false). +// +// The selector is read right-to-left: the rightmost label is the +// immediate child of the parent's `map` tree, matching DNS ordering. +func applySelector(root map[string]any, selector string) (map[string]any, bool) { + if selector == "" { + return root, true + } + labels := splitNonEmpty(selector, '.') + if len(labels) == 0 { + return root, true + } + // Reverse in place (DNS rightmost-first). + for i, j := 0, len(labels)-1; i < j; i, j = i+1, j-1 { + labels[i], labels[j] = labels[j], labels[i] + } + + current := root + for _, label := range labels { + m, ok := current["map"].(map[string]any) + if !ok { + return nil, false + } + var child map[string]any + if c, ok := m[label].(map[string]any); ok { + child = c + } else if c, ok := m["*"].(map[string]any); ok { + child = c + } else if c, ok := m[""].(map[string]any); ok { + child = c + } else { + return nil, false + } + current = child + } + return current, true +} + +func splitNonEmpty(s string, sep byte) []string { + if s == "" { + return nil + } + parts := strings.Split(s, string(sep)) + out := parts[:0] + for _, p := range parts { + if p != "" { + out = append(out, p) + } + } + return out +} + +func tryParseObject(rawJSON string) (map[string]any, bool) { + if rawJSON == "" { + return nil, false + } + var dec any + if err := json.Unmarshal([]byte(rawJSON), &dec); err != nil { + return nil, false + } + obj, ok := dec.(map[string]any) + if !ok { + return nil, false + } + return obj, true +} + +func removeImportKey(obj map[string]any) map[string]any { + if _, has := obj["import"]; !has { + out := make(map[string]any, len(obj)) + for k, v := range obj { + out[k] = v + } + return out + } + out := make(map[string]any, len(obj)-1) + for k, v := range obj { + if k == "import" { + continue + } + out[k] = v + } + return out +} + +// importOp is one parsed entry from an `import` directive. +type importOp struct { + name string + selector string // DNS dotted, possibly empty. +} + +// parseImportItem converts the `import` value into a flat list of +// importOps. The bool return is false only when the value is so +// malformed that no operations can be salvaged at all; in that case +// the caller drops the `import` key and keeps the importer's own +// fields. An empty (but well-formed) list returns ([], true). +// +// Accepted shapes (Kotlin reference parity): +// - canonical: [["d/foo"], ["d/bar","sub"]] +// - bare string: "d/foo" → [{d/foo, ""}] +// - single-arr: ["d/foo"] → [{d/foo, ""}] +// - pair-arr: ["d/foo","sub"] → [{d/foo, "sub"}] +// +// Everything else (number, bool, object, mixed arrays) is malformed. +func parseImportItem(item any) ([]importOp, bool) { + switch v := item.(type) { + case string: + name := strings.TrimSpace(v) + if name == "" { + return nil, false + } + return []importOp{{name: name, selector: ""}}, true + case []any: + if len(v) == 0 { + return nil, true + } + // Canonical: first element is itself an array → array-of-arrays. + if _, firstIsArr := v[0].([]any); firstIsArr { + ops := make([]importOp, 0, len(v)) + for _, entry := range v { + inner, ok := entry.([]any) + if !ok { + continue + } + op, ok := opFromArray(inner) + if !ok { + continue + } + ops = append(ops, op) + } + return ops, true + } + // Shorthand: ["name"] or ["name","selector"]. + op, ok := opFromArray(v) + if !ok { + return nil, false + } + return []importOp{op}, true + default: + return nil, false + } +} + +func opFromArray(arr []any) (importOp, bool) { + if len(arr) == 0 { + return importOp{}, false + } + name, ok := arr[0].(string) + if !ok { + return importOp{}, false + } + name = strings.TrimSpace(name) + if name == "" { + return importOp{}, false + } + selector := "" + if len(arr) >= 2 { + s, ok := arr[1].(string) + if !ok { + return importOp{}, false + } + selector = strings.TrimSpace(s) + } + // Trailing dot is forbidden by spec; treat as malformed → no selector. + if strings.HasSuffix(selector, ".") { + return importOp{}, false + } + return importOp{name: name, selector: selector}, true +} diff --git a/pkg/nip05namecoin/import_test.go b/pkg/nip05namecoin/import_test.go new file mode 100644 index 0000000..716cdb0 --- /dev/null +++ b/pkg/nip05namecoin/import_test.go @@ -0,0 +1,427 @@ +package nip05namecoin + +import ( + "context" + "encoding/json" + "errors" + "testing" +) + +// parseObj is a small helper that decodes a JSON literal into the +// generic map[string]any shape expandImports operates on. Tests that +// build their root via parseObj read more naturally than ones that +// hand-construct nested map literals. +func parseObj(t *testing.T, s string) map[string]any { + t.Helper() + var out map[string]any + if err := json.Unmarshal([]byte(s), &out); err != nil { + t.Fatalf("parse %q: %v", s, err) + } + return out +} + +// fakeLookup builds a deterministic lookup function backed by an +// in-memory map. Tests assert both the merged output and the lookup +// invocation order (e.g. for the zero-extra-I/O regression guard). +type fakeLookup struct { + values map[string]string + calls []string + // hookOnMiss lets a single test simulate a transport-level + // failure (panic, error) from the lookup; returns "" when nil. + hookOnMiss func(name string) string +} + +func (f *fakeLookup) get(name string) string { + f.calls = append(f.calls, name) + if v, ok := f.values[name]; ok { + return v + } + if f.hookOnMiss != nil { + return f.hookOnMiss(name) + } + return "" +} + +// ── 1. Pure unit tests — expandImports in isolation. ──────────────────── + +func TestExpandImports_NoImportKeyIsPassthrough(t *testing.T) { + // Spec rule #1: no `import` key → zero extra I/O. + obj := parseObj(t, `{"ip":"1.2.3.4"}`) + fl := &fakeLookup{} + out := expandImports(obj, fl.get, DefaultMaxImportDepth) + if len(fl.calls) != 0 { + t.Fatalf("lookup must not be called for non-import records, got %v", fl.calls) + } + if out["ip"] != "1.2.3.4" { + t.Fatalf("expected ip preserved, got %#v", out) + } +} + +func TestExpandImports_StringShorthand(t *testing.T) { + obj := parseObj(t, `{"import":"d/lib","ip":"1.1.1.1"}`) + fl := &fakeLookup{values: map[string]string{ + "d/lib": `{"ip":"9.9.9.9","nostr":{"names":{"_":"abc"}}}`, + }} + out := expandImports(obj, fl.get, DefaultMaxImportDepth) + if out["ip"] != "1.1.1.1" { + t.Fatalf("importer ip must win, got %#v", out["ip"]) + } + nostrObj, ok := out["nostr"].(map[string]any) + if !ok { + t.Fatalf("nostr not an object: %#v", out["nostr"]) + } + names, _ := nostrObj["names"].(map[string]any) + if names["_"] != "abc" { + t.Fatalf("imported nostr.names not merged: %#v", names) + } + if _, has := out["import"]; has { + t.Fatalf("import key must be stripped from result") + } +} + +func TestExpandImports_ArrayShorthand(t *testing.T) { + obj := parseObj(t, `{"import":["d/lib"]}`) + fl := &fakeLookup{values: map[string]string{ + "d/lib": `{"tag":"from-lib"}`, + }} + out := expandImports(obj, fl.get, DefaultMaxImportDepth) + if out["tag"] != "from-lib" { + t.Fatalf("imported tag missing: %#v", out) + } +} + +func TestExpandImports_PairArrayShorthandWithSelector(t *testing.T) { + obj := parseObj(t, `{"import":["d/lib","relay"]}`) + fl := &fakeLookup{values: map[string]string{ + "d/lib": `{"ip":"1.1.1.1","map":{"relay":{"ip":"7.7.7.7","tag":"selected"}}}`, + }} + out := expandImports(obj, fl.get, DefaultMaxImportDepth) + // Selector descended into map.relay; top-level ip from lib is + // hidden, the selected subtree's ip surfaces. + if out["ip"] != "7.7.7.7" { + t.Fatalf("selector node ip not surfaced: %#v", out["ip"]) + } + if out["tag"] != "selected" { + t.Fatalf("selector node tag missing: %#v", out) + } +} + +func TestExpandImports_CanonicalArrayOfArrays(t *testing.T) { + obj := parseObj(t, `{"import":[["d/a"],["d/b"]]}`) + fl := &fakeLookup{values: map[string]string{ + "d/a": `{"ip":"10.0.0.1","tag":"from-a"}`, + "d/b": `{"ip":"10.0.0.2","extra":"from-b"}`, + }} + out := expandImports(obj, fl.get, DefaultMaxImportDepth) + // Later imports override earlier ones (Kotlin reference parity). + if out["ip"] != "10.0.0.2" { + t.Fatalf("later import should override earlier ip, got %#v", out["ip"]) + } + if out["tag"] != "from-a" { + t.Fatalf("from-a tag should survive: %#v", out) + } + if out["extra"] != "from-b" { + t.Fatalf("from-b extra should survive: %#v", out) + } +} + +func TestExpandImports_ImporterWinsOnPlainKeys(t *testing.T) { + obj := parseObj(t, `{"import":"d/lib","ip":"1.1.1.1","extra":"local"}`) + fl := &fakeLookup{values: map[string]string{ + "d/lib": `{"ip":"9.9.9.9","extra":"remote","only-imported":"yes"}`, + }} + out := expandImports(obj, fl.get, DefaultMaxImportDepth) + if out["ip"] != "1.1.1.1" || out["extra"] != "local" || out["only-imported"] != "yes" { + t.Fatalf("importer-wins merge wrong: %#v", out) + } +} + +func TestExpandImports_NullSuppressesImportedKey(t *testing.T) { + // ifa-0001: null in importer is "present" — semantic delete. + obj := parseObj(t, `{"import":"d/lib","ip":null}`) + fl := &fakeLookup{values: map[string]string{ + "d/lib": `{"ip":"9.9.9.9","other":"keep"}`, + }} + out := expandImports(obj, fl.get, DefaultMaxImportDepth) + v, present := out["ip"] + if !present { + t.Fatalf("ip key must remain (as null), got missing") + } + if v != nil { + t.Fatalf("ip should be JSON null, got %#v", v) + } + if out["other"] != "keep" { + t.Fatalf("non-suppressed imports must survive: %#v", out) + } +} + +func TestExpandImports_RecursionDepthFourHappyPath(t *testing.T) { + obj := parseObj(t, `{"import":"d/a"}`) + fl := &fakeLookup{values: map[string]string{ + "d/a": `{"import":"d/b","layer":"a"}`, + "d/b": `{"import":"d/c","layer":"b"}`, + "d/c": `{"import":"d/d","layer":"c"}`, + "d/d": `{"layer":"d","deep":"reached"}`, + }} + out := expandImports(obj, fl.get, DefaultMaxImportDepth) + if out["layer"] != "a" { + t.Fatalf("each layer overrides; expected layer=a got %#v", out["layer"]) + } + if out["deep"] != "reached" { + t.Fatalf("4-deep value must reach the top: %#v", out) + } +} + +func TestExpandImports_RecursionTruncatedAtBudget(t *testing.T) { + obj := parseObj(t, `{"import":"d/a","local":"keep"}`) + fl := &fakeLookup{values: map[string]string{ + "d/a": `{"import":"d/b","tag":"from-a"}`, + "d/b": `{"tag":"from-b","leaf":"wont-show"}`, + }} + out := expandImports(obj, fl.get, 1) + if out["tag"] != "from-a" { + t.Fatalf("budget=1: only d/a should be merged, got tag=%#v", out["tag"]) + } + if out["local"] != "keep" { + t.Fatalf("importer keys must survive truncation: %#v", out) + } + if _, has := out["leaf"]; has { + t.Fatalf("d/b should never have been visited at budget=1") + } +} + +func TestExpandImports_LookupReturnsNilTreatedAsEmpty(t *testing.T) { + obj := parseObj(t, `{"import":"d/missing","local":"survives"}`) + out := expandImports(obj, func(string) string { return "" }, DefaultMaxImportDepth) + if out["local"] != "survives" { + t.Fatalf("importer survives missing import: %#v", out) + } + if _, has := out["import"]; has { + t.Fatalf("import key must be stripped even on miss") + } +} + +func TestExpandImports_LookupPanicTreatedAsEmpty(t *testing.T) { + // Go's idiom for "throws" — make sure a panicking lookup doesn't + // nuke the whole resolution. We wrap the lookup so the panic is + // localised to one call and the importer's own fields apply. + obj := parseObj(t, `{"import":"d/explode","local":"survives"}`) + defer func() { + if r := recover(); r != nil { + t.Fatalf("expandImports must not propagate lookup panics; got %v", r) + } + }() + lookup := func(name string) (result string) { + defer func() { + if r := recover(); r != nil { + result = "" + } + }() + panicLookup(name) + return "" + } + out := expandImports(obj, lookup, DefaultMaxImportDepth) + if out["local"] != "survives" { + t.Fatalf("importer survives panicking lookup: %#v", out) + } +} + +// panicLookup is a tiny named function so the recover() above clearly +// shows the test surfaces the intended panic. +func panicLookup(name string) { panic("boom: " + name) } + +func TestExpandImports_MalformedImportedJSON(t *testing.T) { + obj := parseObj(t, `{"import":"d/broken","local":"keep"}`) + fl := &fakeLookup{values: map[string]string{ + "d/broken": `not valid json {{{`, + }} + out := expandImports(obj, fl.get, DefaultMaxImportDepth) + if out["local"] != "keep" { + t.Fatalf("importer keys must survive malformed imports: %#v", out) + } +} + +func TestExpandImports_MalformedImportValueIsNoOp(t *testing.T) { + cases := []string{ + `{"import":42,"local":"keep"}`, + `{"import":true,"local":"keep"}`, + `{"import":{"foo":"bar"},"local":"keep"}`, + } + for _, raw := range cases { + obj := parseObj(t, raw) + out := expandImports(obj, func(string) string { + t.Fatalf("lookup must not be called for malformed import %q", raw) + return "" + }, DefaultMaxImportDepth) + if out["local"] != "keep" { + t.Fatalf("%s: importer keys must survive malformed import: %#v", raw, out) + } + if _, has := out["import"]; has { + t.Fatalf("%s: import key must be stripped", raw) + } + } +} + +func TestExpandImports_CycleProtection(t *testing.T) { + obj := parseObj(t, `{"import":"d/a","local":"top"}`) + fl := &fakeLookup{values: map[string]string{ + "d/a": `{"import":"d/b","fromA":"yes"}`, + "d/b": `{"import":"d/a","fromB":"yes"}`, + }} + done := make(chan map[string]any, 1) + go func() { + done <- expandImports(obj, fl.get, DefaultMaxImportDepth) + }() + select { + case out := <-done: + if out["local"] != "top" { + t.Fatalf("importer keys must survive cycle: %#v", out) + } + // One of fromA / fromB must be present (the cycle break + // point is an implementation detail; both visits before the + // break should still contribute). + if _, hasA := out["fromA"]; !hasA { + if _, hasB := out["fromB"]; !hasB { + t.Fatalf("expected at least one of fromA/fromB to survive: %#v", out) + } + } + } +} + +func TestExpandImports_SelectorMultipleLabelsDNSOrder(t *testing.T) { + // selector "a.b" → descend map.b first, then map.a (rightmost label first). + obj := parseObj(t, `{"import":[["d/lib","a.b"]]}`) + fl := &fakeLookup{values: map[string]string{ + "d/lib": `{"map":{"b":{"map":{"a":{"value":"deep"}}}}}`, + }} + out := expandImports(obj, fl.get, DefaultMaxImportDepth) + if out["value"] != "deep" { + t.Fatalf("multi-label selector wrong: %#v", out) + } +} + +func TestExpandImports_SelectorWildcardFallback(t *testing.T) { + obj := parseObj(t, `{"import":["d/lib","ghost"]}`) + fl := &fakeLookup{values: map[string]string{ + "d/lib": `{"map":{"*":{"value":"wildcard"}}}`, + }} + out := expandImports(obj, fl.get, DefaultMaxImportDepth) + if out["value"] != "wildcard" { + t.Fatalf("expected wildcard fallback, got %#v", out) + } +} + +// ── 2. Integration tests — queryIdentifierWithLookup end-to-end. ──────── + +func TestQueryIdentifier_FollowsImportForSharedNostrNamesBlock(t *testing.T) { + // Real-world testls.bit: apex `d/testls` delegates `nostr.names` + // to `dd/testls` via an `import`. Without import support both + // the bare lookup and the named-local-part lookup fail. + fl := &fakeLookup{values: map[string]string{ + "d/testls": `{"import":"dd/testls","ip":"107.152.38.155"}`, + "dd/testls": `{"nostr":{"names":{"_":"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c","m":"6cdebccabda1dfa058ab85352a79509b592b2bdfa0370325e28ec1cb4f18667d"}}}`, + }} + ctx := context.Background() + + pp, err := queryIdentifierWithLookup(ctx, "testls.bit", fl.get) + if err != nil { + t.Fatalf("bare testls.bit: unexpected error %v", err) + } + if pp.PublicKey != "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" { + t.Fatalf("bare resolved to wrong pubkey: %s", pp.PublicKey) + } + + pp2, err := queryIdentifierWithLookup(ctx, "m@testls.bit", fl.get) + if err != nil { + t.Fatalf("m@testls.bit: unexpected error %v", err) + } + if pp2.PublicKey != "6cdebccabda1dfa058ab85352a79509b592b2bdfa0370325e28ec1cb4f18667d" { + t.Fatalf("named local-part resolved to wrong pubkey: %s", pp2.PublicKey) + } +} + +func TestQueryIdentifier_NamedLocalPartResolvesAcrossImport(t *testing.T) { + // Same flow but the local-part exists only in the imported + // names block — exercises that the importer's other top-level + // fields don't accidentally shadow nostr.names. + fl := &fakeLookup{values: map[string]string{ + "d/foo": `{"import":"dd/foo","ip":"1.2.3.4"}`, + "dd/foo": `{"nostr":{"names":{ + "alice":"aaaa000000000000000000000000000000000000000000000000000000000001" + }}}`, + }} + pp, err := queryIdentifierWithLookup(context.Background(), "alice@foo.bit", fl.get) + if err != nil { + t.Fatalf("alice@foo.bit: unexpected error %v", err) + } + if pp.PublicKey != "aaaa000000000000000000000000000000000000000000000000000000000001" { + t.Fatalf("wrong pubkey resolved: %s", pp.PublicKey) + } +} + +func TestQueryIdentifier_NoImportRecordIssuesExactlyOneLookup(t *testing.T) { + // Regression guard for the "zero extra I/O" property — a plain + // record must hit ElectrumX exactly once, not query an + // "import" sibling that doesn't exist. + fl := &fakeLookup{values: map[string]string{ + "d/plain": `{"nostr":{"names":{"_":"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"}}}`, + }} + pp, err := queryIdentifierWithLookup(context.Background(), "plain.bit", fl.get) + if err != nil { + t.Fatalf("plain.bit: unexpected error %v", err) + } + if pp.PublicKey != "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" { + t.Fatalf("wrong pubkey: %s", pp.PublicKey) + } + if len(fl.calls) != 1 || fl.calls[0] != "d/plain" { + t.Fatalf("expected exactly one lookup of d/plain, got %v", fl.calls) + } +} + +func TestQueryIdentifier_ImporterWinsOnNostrNamesMap(t *testing.T) { + // Importer declares its own nostr.names.m; imported value + // declares a different one. Importer wins. + fl := &fakeLookup{values: map[string]string{ + "d/testls": `{"import":"dd/testls","nostr":{"names":{"m":"aaaa000000000000000000000000000000000000000000000000000000000001"}}}`, + "dd/testls": `{"nostr":{"names":{"m":"bbbb000000000000000000000000000000000000000000000000000000000002"}}}`, + }} + pp, err := queryIdentifierWithLookup(context.Background(), "m@testls.bit", fl.get) + if err != nil { + t.Fatalf("m@testls.bit: unexpected error %v", err) + } + if pp.PublicKey != "aaaa000000000000000000000000000000000000000000000000000000000001" { + t.Fatalf("importer-wins violated; got %s", pp.PublicKey) + } +} + +func TestQueryIdentifier_FailedImportDoesNotBreakLocalNames(t *testing.T) { + // Importer has its own nostr.names; imported sibling is + // missing. Resolution still succeeds from importer's own data. + fl := &fakeLookup{values: map[string]string{ + "d/testls": `{"import":"dd/missing","nostr":{"names":{"_":"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"}}}`, + // dd/missing is intentionally NOT registered. + }} + pp, err := queryIdentifierWithLookup(context.Background(), "testls.bit", fl.get) + if err != nil { + t.Fatalf("testls.bit: unexpected error %v", err) + } + if pp.PublicKey != "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" { + t.Fatalf("wrong pubkey: %s", pp.PublicKey) + } +} + +func TestQueryIdentifier_ImportTargetLacksNostrFieldYieldsClearError(t *testing.T) { + // Both importer and imported lack nostr → the existing + // no-nostr-field error must still surface (no silent success). + fl := &fakeLookup{values: map[string]string{ + "d/testls": `{"import":"dd/testls"}`, + "dd/testls": `{"ip":"1.2.3.4"}`, + }} + _, err := queryIdentifierWithLookup(context.Background(), "testls.bit", fl.get) + if err == nil { + t.Fatalf("expected no-nostr-field error, got nil") + } + if errors.Is(err, ErrNameNotFound) { + t.Fatalf("wrong error class: %v", err) + } +} diff --git a/pkg/nip05namecoin/nip05namecoin.go b/pkg/nip05namecoin/nip05namecoin.go new file mode 100644 index 0000000..b9985df --- /dev/null +++ b/pkg/nip05namecoin/nip05namecoin.go @@ -0,0 +1,318 @@ +// Package nip05namecoin implements Namecoin (.bit) name resolution for +// NIP-05 identifiers, returning the underlying Nostr pubkey via public +// ElectrumX servers. +// +// HAVEN uses this package at startup to expand its whitelist with +// pubkeys resolved from operator-supplied .bit names, in addition to +// the existing npub-based whitelist file. +// +// Spec reference: https://github.com/nostr-protocol/nips/pull/2349 +package nip05namecoin + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "regexp" + "strings" + + "github.com/nbd-wtf/go-nostr" +) + +var hexPubKeyRegex = regexp.MustCompile(`^[0-9a-fA-F]{64}$`) + +// IsValidIdentifier reports whether an identifier should be routed to +// Namecoin resolution instead of DNS-based NIP-05. It matches: +// +// - ".bit" +// - "alice@.bit" +// - "d/" +// - "id/" +// +// The name mirrors nip05.IsValidIdentifier so callers can use the two +// as a chained check. +func IsValidIdentifier(identifier string) bool { + if identifier == "" { + return false + } + norm := strings.ToLower(strings.TrimSpace(identifier)) + norm = strings.TrimPrefix(norm, "nostr:") + if strings.HasPrefix(norm, "d/") || strings.HasPrefix(norm, "id/") { + return true + } + return strings.HasSuffix(norm, ".bit") +} + +// ParsedIdentifier captures the Namecoin name we need to query and the +// local-part within its value. Exported so tests in other packages can +// inspect parser output if needed; most callers should use +// ParseIdentifier and pass the result back to QueryIdentifier directly. +type ParsedIdentifier struct { + NamecoinName string // e.g. "d/example" or "id/alice" + LocalPart string // e.g. "alice", or "_" for the root + IsDomain bool // true for d/ names, false for id/ names +} + +// ParseIdentifier breaks a user-supplied identifier into the Namecoin +// name + local-part pair. Returns nil for anything that isn't a valid +// .bit / d/ / id/ identifier. +func ParseIdentifier(raw string) *ParsedIdentifier { + input := strings.TrimSpace(raw) + // Strip an optional NIP-21 "nostr:" URI prefix. + if len(input) >= 6 && strings.EqualFold(input[:6], "nostr:") { + input = input[6:] + } + lower := strings.ToLower(input) + + // Explicit namespace references. + if strings.HasPrefix(lower, "d/") { + return &ParsedIdentifier{NamecoinName: lower, LocalPart: "_", IsDomain: true} + } + if strings.HasPrefix(lower, "id/") { + return &ParsedIdentifier{NamecoinName: lower, LocalPart: "_", IsDomain: false} + } + + // NIP-05 shape: user@domain.bit + if strings.Contains(input, "@") && strings.HasSuffix(lower, ".bit") { + parts := strings.SplitN(input, "@", 2) + if len(parts) != 2 { + return nil + } + local := strings.ToLower(parts[0]) + if local == "" { + local = "_" + } + domain := strings.TrimSuffix(strings.ToLower(parts[1]), ".bit") + if domain == "" { + return nil + } + return &ParsedIdentifier{ + NamecoinName: "d/" + domain, + LocalPart: local, + IsDomain: true, + } + } + + // Bare domain: example.bit + if strings.HasSuffix(lower, ".bit") { + domain := strings.TrimSuffix(lower, ".bit") + if domain == "" { + return nil + } + return &ParsedIdentifier{ + NamecoinName: "d/" + domain, + LocalPart: "_", + IsDomain: true, + } + } + + return nil +} + +// QueryIdentifier resolves a Namecoin .bit (or d/ / id/) identifier +// into a nostr.ProfilePointer. The signature mirrors +// nip05.QueryIdentifier from nbd-wtf/go-nostr so callers can fall +// through from one to the other without reshaping their code. +// +// The context deadline is respected: ElectrumX calls honour the same +// timeout the caller passed in. Records using the ifa-0001 §"import" +// chain (e.g. testls.bit, which splits its `nostr.names` block into +// a sibling name) are transparently expanded before extraction. +func QueryIdentifier(ctx context.Context, identifier string) (*nostr.ProfilePointer, error) { + return queryIdentifierWithLookup(ctx, identifier, nil) +} + +// queryIdentifierWithLookup is the shared implementation behind +// QueryIdentifier. When lookup is nil it builds a real ElectrumX +// client and uses DefaultElectrumXServers; tests can pass a +// hermetic in-memory lookup to exercise the import-chain wiring. +func queryIdentifierWithLookup(ctx context.Context, identifier string, lookup nameValueLookup) (*nostr.ProfilePointer, error) { + parsed := ParseIdentifier(identifier) + if parsed == nil { + return nil, fmt.Errorf("nip05namecoin: not a Namecoin identifier: %q", identifier) + } + + if lookup == nil { + client := NewElectrumClient() + lookup = func(name string) string { + result, err := client.NameShowWithFallback(ctx, name, DefaultElectrumXServers) + if err != nil || result == nil { + return "" + } + return result.Value + } + } + + rootValue := lookup(parsed.NamecoinName) + if rootValue == "" { + return nil, ErrNameNotFound + } + + pubkeyHex, relays, err := extractNostrFromValue(rootValue, parsed, lookup) + if err != nil { + return nil, err + } + + if !nostr.IsValidPublicKey(pubkeyHex) { + return nil, fmt.Errorf("nip05namecoin: invalid pubkey %q in name value", pubkeyHex) + } + return &nostr.ProfilePointer{ + PublicKey: pubkeyHex, + Relays: relays, + }, nil +} + +// extractNostrFromValue parses the Namecoin name value JSON and pulls +// the relevant nostr pubkey + relay list out of it. Supports both the +// simple `"nostr": "hex"` form and the extended +// `"nostr": { "names": {...}, "relays": {...} }` form used by Amethyst. +// +// Records using the ifa-0001 §"import" chain are expanded via +// `lookup` before extraction; pass a nil lookup to disable import +// expansion entirely (in which case records with an `import` key but +// no inline `nostr` field will return the existing no-nostr-field +// error, preserving pre-import-chain behaviour). +func extractNostrFromValue(valueJSON string, parsed *ParsedIdentifier, lookup nameValueLookup) (string, []string, error) { + var rootGeneric map[string]any + if err := json.Unmarshal([]byte(valueJSON), &rootGeneric); err != nil { + return "", nil, fmt.Errorf("nip05namecoin: name value is not valid JSON: %w", err) + } + // Expand any ifa-0001 §"import" chain so the existing extractor + // sees a richer, fully-merged object. Records without an `import` + // key pay zero extra I/O cost (expandImports short-circuits). + if lookup != nil { + rootGeneric = expandImports(rootGeneric, lookup, DefaultMaxImportDepth) + } + // Re-encode to RawMessage so the rest of this function — which + // expects RawMessage values for selective decoding — keeps + // working without further changes. + root, err := toRawMessageMap(rootGeneric) + if err != nil { + return "", nil, fmt.Errorf("nip05namecoin: re-encode merged name value: %w", err) + } + nostrRaw, ok := root["nostr"] + if !ok { + return "", nil, errors.New(`nip05namecoin: name value has no "nostr" field`) + } + + // Simple form: "nostr": "hex-pubkey" + var asString string + if err := json.Unmarshal(nostrRaw, &asString); err == nil { + if parsed.IsDomain && parsed.LocalPart != "_" { + return "", nil, fmt.Errorf("nip05namecoin: simple nostr field only supports root lookup, got local-part %q", parsed.LocalPart) + } + if !hexPubKeyRegex.MatchString(asString) { + return "", nil, errors.New("nip05namecoin: nostr field is not a 32-byte hex pubkey") + } + return strings.ToLower(asString), nil, nil + } + + // Extended form: object with "names" and optional "relays". + var asObject map[string]json.RawMessage + if err := json.Unmarshal(nostrRaw, &asObject); err != nil { + return "", nil, fmt.Errorf("nip05namecoin: nostr field is neither string nor object: %w", err) + } + + if parsed.IsDomain { + return extractFromDomainNamesObject(asObject, parsed) + } + return extractFromIdentityObject(asObject, parsed) +} + +func extractFromDomainNamesObject(obj map[string]json.RawMessage, parsed *ParsedIdentifier) (string, []string, error) { + namesRaw, ok := obj["names"] + if !ok { + return "", nil, errors.New(`nip05namecoin: extended nostr object lacks "names"`) + } + var names map[string]string + if err := json.Unmarshal(namesRaw, &names); err != nil { + return "", nil, fmt.Errorf("nip05namecoin: parse names map: %w", err) + } + + // Match priority: exact local-part → "_" root → first entry (only + // when the caller asked for root). + var pickedPubkey string + if v, ok := names[parsed.LocalPart]; ok && hexPubKeyRegex.MatchString(v) { + pickedPubkey = v + } else if v, ok := names["_"]; ok && hexPubKeyRegex.MatchString(v) { + pickedPubkey = v + } else if parsed.LocalPart == "_" { + // First entry (map iteration order is non-deterministic, weak + // fallback — we accept the first valid pubkey). + for _, v := range names { + if hexPubKeyRegex.MatchString(v) { + pickedPubkey = v + break + } + } + } + if pickedPubkey == "" { + return "", nil, fmt.Errorf("nip05namecoin: no valid pubkey for local-part %q", parsed.LocalPart) + } + + relays := extractRelays(obj, pickedPubkey) + return strings.ToLower(pickedPubkey), relays, nil +} + +func extractFromIdentityObject(obj map[string]json.RawMessage, parsed *ParsedIdentifier) (string, []string, error) { + // Try "pubkey" field. + if raw, ok := obj["pubkey"]; ok { + var pk string + if err := json.Unmarshal(raw, &pk); err == nil && hexPubKeyRegex.MatchString(pk) { + var relays []string + if r, ok := obj["relays"]; ok { + _ = json.Unmarshal(r, &relays) + } + return strings.ToLower(pk), relays, nil + } + } + + // Fall back to NIP-05-like "names" with "_" root. + if raw, ok := obj["names"]; ok { + var names map[string]string + if err := json.Unmarshal(raw, &names); err == nil { + if v, ok := names["_"]; ok && hexPubKeyRegex.MatchString(v) { + relays := extractRelays(obj, v) + return strings.ToLower(v), relays, nil + } + } + } + + _ = parsed + return "", nil, errors.New("nip05namecoin: id/ nostr object has no valid pubkey") +} + +// toRawMessageMap re-encodes a generic decoded object as a +// map[string]json.RawMessage so the rest of the extractor (which +// uses RawMessage for selective decoding) can keep its existing +// shape. +func toRawMessageMap(in map[string]any) (map[string]json.RawMessage, error) { + out := make(map[string]json.RawMessage, len(in)) + for k, v := range in { + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + out[k] = b + } + return out, nil +} + +func extractRelays(obj map[string]json.RawMessage, pubkey string) []string { + raw, ok := obj["relays"] + if !ok { + return nil + } + var relayMap map[string][]string + if err := json.Unmarshal(raw, &relayMap); err != nil { + return nil + } + if v, ok := relayMap[strings.ToLower(pubkey)]; ok { + return v + } + if v, ok := relayMap[pubkey]; ok { + return v + } + return nil +} diff --git a/pkg/nip05namecoin/nip05namecoin_test.go b/pkg/nip05namecoin/nip05namecoin_test.go new file mode 100644 index 0000000..03594a3 --- /dev/null +++ b/pkg/nip05namecoin/nip05namecoin_test.go @@ -0,0 +1,281 @@ +package nip05namecoin + +import ( + "encoding/hex" + "strings" + "testing" +) + +func TestIsValidIdentifier(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {"example.bit", true}, + {"alice@example.bit", true}, + {"_@example.bit", true}, + {"Example.Bit", true}, + {" example.bit ", true}, + {"d/example", true}, + {"id/alice", true}, + {"D/example", true}, + {"alice@example.com", false}, + {"example.com", false}, + {"", false}, + {" ", false}, + {"npub1xyz", false}, + {"nostr:alice@example.bit", true}, + {"nostr:example.bit", true}, + {"nostr:npub1xyz", false}, + } + for _, tc := range cases { + if got := IsValidIdentifier(tc.in); got != tc.want { + t.Errorf("IsValidIdentifier(%q) = %v, want %v", tc.in, got, tc.want) + } + } +} + +func TestParseIdentifier(t *testing.T) { + cases := []struct { + in string + want *ParsedIdentifier + }{ + {"example.bit", &ParsedIdentifier{"d/example", "_", true}}, + {"alice@example.bit", &ParsedIdentifier{"d/example", "alice", true}}, + {"_@example.bit", &ParsedIdentifier{"d/example", "_", true}}, + {"ALICE@Example.Bit", &ParsedIdentifier{"d/example", "alice", true}}, + {"d/example", &ParsedIdentifier{"d/example", "_", true}}, + {"id/alice", &ParsedIdentifier{"id/alice", "_", false}}, + {"nostr:alice@example.bit", &ParsedIdentifier{"d/example", "alice", true}}, + {"Nostr:example.bit", &ParsedIdentifier{"d/example", "_", true}}, + {".bit", nil}, + {"@.bit", nil}, + {"not a name", nil}, + {"", nil}, + } + for _, tc := range cases { + got := ParseIdentifier(tc.in) + if tc.want == nil { + if got != nil { + t.Errorf("ParseIdentifier(%q) = %+v, want nil", tc.in, got) + } + continue + } + if got == nil { + t.Errorf("ParseIdentifier(%q) = nil, want %+v", tc.in, tc.want) + continue + } + if got.NamecoinName != tc.want.NamecoinName || + got.LocalPart != tc.want.LocalPart || + got.IsDomain != tc.want.IsDomain { + t.Errorf("ParseIdentifier(%q) = %+v, want %+v", tc.in, got, tc.want) + } + } +} + +func TestBuildNameIndexScript(t *testing.T) { + // Layout: OP_NAME_UPDATE | 0x08 | "d/testls" (8 bytes) | 0x00 (empty push) | OP_2DROP | OP_DROP | OP_RETURN + // Expected hex: 53 08 642f746573746c73 00 6d 75 6a + name := []byte("d/testls") + got := buildNameIndexScript(name) + want, _ := hex.DecodeString("5308642f746573746c73006d756a") + if hex.EncodeToString(got) != hex.EncodeToString(want) { + t.Fatalf("name-index script mismatch:\n got: %x\n want: %x", got, want) + } +} + +func TestPushData(t *testing.T) { + if got := pushData([]byte{1, 2, 3}); hex.EncodeToString(got) != "03010203" { + t.Errorf("pushData(3 bytes) = %x, want 03010203", got) + } + data := make([]byte, 0x4c) + got := pushData(data) + if got[0] != opPushData1 || got[1] != 0x4c { + t.Errorf("pushData(0x4c bytes) should start with OP_PUSHDATA1 0x4c, got %x", got[:2]) + } + big := make([]byte, 256) + got = pushData(big) + if got[0] != opPushData2 || got[1] != 0x00 || got[2] != 0x01 { + t.Errorf("pushData(256 bytes) should start with OP_PUSHDATA2 0x00 0x01, got %x", got[:3]) + } +} + +func TestElectrumScriptHash(t *testing.T) { + // SHA-256("") reversed. + got := electrumScriptHash([]byte{}) + want := "55b852781b9995a44c939b64e441ae2724b96f99c8f4fb9a141cfc9842c4b0e3" + if got != want { + t.Errorf("electrumScriptHash(empty) = %s, want %s", got, want) + } +} + +func TestElectrumScriptHashDTestLS(t *testing.T) { + // "d/testls" scripthash matches the long-standing reference value + // served by public Namecoin ElectrumX servers. + script := buildNameIndexScript([]byte("d/testls")) + got := electrumScriptHash(script) + want := "b519574e96740a4b3627674a0708e71a73e654a95117fc828b8e177a0579ab42" + if got != want { + t.Errorf("electrumScriptHash(d/testls) = %s, want %s", got, want) + } +} + +func TestReadPushData(t *testing.T) { + script, _ := hex.DecodeString("04deadbeef") + data, next, err := readPushData(script, 0) + if err != nil { + t.Fatal(err) + } + if hex.EncodeToString(data) != "deadbeef" || next != 5 { + t.Errorf("direct push: got data=%x next=%d", data, next) + } + + script, _ = hex.DecodeString("4c03aabbcc") + data, next, err = readPushData(script, 0) + if err != nil { + t.Fatal(err) + } + if hex.EncodeToString(data) != "aabbcc" || next != 5 { + t.Errorf("OP_PUSHDATA1: got data=%x next=%d", data, next) + } + + script, _ = hex.DecodeString("4d02001122") + data, next, err = readPushData(script, 0) + if err != nil { + t.Fatal(err) + } + if hex.EncodeToString(data) != "1122" || next != 5 { + t.Errorf("OP_PUSHDATA2: got data=%x next=%d", data, next) + } + + script = []byte{0x00} + data, next, err = readPushData(script, 0) + if err != nil { + t.Fatal(err) + } + if len(data) != 0 || next != 1 { + t.Errorf("OP_0: got data=%x next=%d", data, next) + } +} + +func TestParseNameScript(t *testing.T) { + value := `{"nostr":"6cdebccabda1dfa058ab85352a79509b592b2bdfa0370325e28ec1cb4f18667d"}` + script := []byte{opNameUpdate} + script = append(script, pushData([]byte("d/testls"))...) + script = append(script, pushData([]byte(value))...) + script = append(script, op2Drop, opDrop) + script = append(script, []byte{0x76, 0xa9, 0x14}...) + + name, gotValue, err := parseNameScript(script) + if err != nil { + t.Fatalf("parseNameScript: %v", err) + } + if name != "d/testls" { + t.Errorf("name = %q, want d/testls", name) + } + if gotValue != value { + t.Errorf("value mismatch: got %q want %q", gotValue, value) + } +} + +func TestExtractNostrFromValue_SimpleForm(t *testing.T) { + value := `{"nostr":"b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9"}` + pubkey, relays, err := extractNostrFromValue(value, &ParsedIdentifier{"d/example", "_", true}, nil) + if err != nil { + t.Fatal(err) + } + if pubkey != "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9" { + t.Errorf("pubkey = %q", pubkey) + } + if len(relays) != 0 { + t.Errorf("expected no relays, got %v", relays) + } + + if _, _, err := extractNostrFromValue(value, &ParsedIdentifier{"d/example", "alice", true}, nil); err == nil { + t.Error("expected error for simple-form + non-root local-part") + } +} + +func TestExtractNostrFromValue_ExtendedForm(t *testing.T) { + value := `{ + "nostr": { + "names": { + "_": "aaaa000000000000000000000000000000000000000000000000000000000001", + "alice": "bbbb000000000000000000000000000000000000000000000000000000000002" + }, + "relays": { + "bbbb000000000000000000000000000000000000000000000000000000000002": ["wss://relay.example.com"] + } + } + }` + pk, _, err := extractNostrFromValue(value, &ParsedIdentifier{"d/example", "_", true}, nil) + if err != nil { + t.Fatal(err) + } + if pk != "aaaa000000000000000000000000000000000000000000000000000000000001" { + t.Errorf("root pubkey = %q", pk) + } + pk, relays, err := extractNostrFromValue(value, &ParsedIdentifier{"d/example", "alice", true}, nil) + if err != nil { + t.Fatal(err) + } + if pk != "bbbb000000000000000000000000000000000000000000000000000000000002" { + t.Errorf("alice pubkey = %q", pk) + } + if len(relays) != 1 || relays[0] != "wss://relay.example.com" { + t.Errorf("alice relays = %v", relays) + } +} + +func TestExtractNostrFromValue_FallbackToRoot(t *testing.T) { + value := `{"nostr":{"names":{"_":"aaaa000000000000000000000000000000000000000000000000000000000001"}}}` + pk, _, err := extractNostrFromValue(value, &ParsedIdentifier{"d/example", "nonexistent", true}, nil) + if err != nil { + t.Fatal(err) + } + if pk != "aaaa000000000000000000000000000000000000000000000000000000000001" { + t.Errorf("pubkey = %q", pk) + } +} + +func TestExtractNostrFromValue_IdentityObject(t *testing.T) { + value := `{"nostr":{"pubkey":"dddd000000000000000000000000000000000000000000000000000000000004","relays":["wss://relay.id.example"]}}` + pk, relays, err := extractNostrFromValue(value, &ParsedIdentifier{"id/alice", "_", false}, nil) + if err != nil { + t.Fatal(err) + } + if pk != "dddd000000000000000000000000000000000000000000000000000000000004" { + t.Errorf("id pubkey = %q", pk) + } + if len(relays) != 1 || relays[0] != "wss://relay.id.example" { + t.Errorf("id relays = %v", relays) + } +} + +func TestExtractNostrFromValue_RejectsInvalidPubkey(t *testing.T) { + if _, _, err := extractNostrFromValue(`{"nostr":"abcdef"}`, &ParsedIdentifier{"d/x", "_", true}, nil); err == nil { + t.Error("expected error for short pubkey") + } + if _, _, err := extractNostrFromValue( + `{"nostr":"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"}`, + &ParsedIdentifier{"d/x", "_", true}, + nil, + ); err == nil { + t.Error("expected error for non-hex pubkey") + } +} + +func TestPinnedCertsParse(t *testing.T) { + pool := buildPinnedCertPool() + if pool == nil { + t.Fatal("nil cert pool") + } + for i, pem := range PinnedElectrumXCerts { + if !strings.Contains(pem, "BEGIN CERTIFICATE") || !strings.Contains(pem, "END CERTIFICATE") { + t.Errorf("pinned cert %d missing BEGIN/END markers", i) + } + } + if fps := pinnedFingerprints(); len(fps) != len(PinnedElectrumXCerts) { + t.Errorf("pinned fingerprints = %d, want %d (some certs failed to parse)", len(fps), len(PinnedElectrumXCerts)) + } +} diff --git a/pkg/nip05namecoin/script.go b/pkg/nip05namecoin/script.go new file mode 100644 index 0000000..a6374a8 --- /dev/null +++ b/pkg/nip05namecoin/script.go @@ -0,0 +1,155 @@ +package nip05namecoin + +import ( + "crypto/sha256" + "encoding/hex" + "errors" +) + +// Namecoin script opcodes used by the name-index script and NAME_UPDATE +// outputs. Matches electrumx/lib/coins.py (Namecoin fork) and the Kotlin +// reference implementation. +const ( + opNameUpdate byte = 0x53 // OP_3, repurposed by Namecoin as OP_NAME_UPDATE + op2Drop byte = 0x6d + opDrop byte = 0x75 + opReturn byte = 0x6a + opPushData1 byte = 0x4c + opPushData2 byte = 0x4d + opPushData4 byte = 0x4e +) + +// buildNameIndexScript constructs the canonical script used by the +// Namecoin ElectrumX fork to index names on-chain. +// +// Format: +// +// OP_NAME_UPDATE OP_2DROP OP_DROP OP_RETURN +// +// The resulting script's SHA-256 (reversed, hex-encoded) is the +// scripthash queried via `blockchain.scripthash.get_history`. +func buildNameIndexScript(nameBytes []byte) []byte { + out := make([]byte, 0, 4+len(nameBytes)+4) + out = append(out, opNameUpdate) + out = append(out, pushData(nameBytes)...) + out = append(out, pushData(nil)...) + out = append(out, op2Drop, opDrop, opReturn) + return out +} + +// pushData returns the Bitcoin-style push-data encoding of `data`. +func pushData(data []byte) []byte { + n := len(data) + switch { + case n < int(opPushData1): // 0x4c + return append([]byte{byte(n)}, data...) + case n <= 0xff: + return append([]byte{opPushData1, byte(n)}, data...) + default: + hi := byte((n >> 8) & 0xff) + lo := byte(n & 0xff) + return append([]byte{opPushData2, lo, hi}, data...) + } +} + +// electrumScriptHash computes the Electrum scripthash: SHA-256 of the +// script, byte-reversed, then hex-encoded. This is the format expected +// by `blockchain.scripthash.get_history` and friends. +// +// Worked example: for "d/testls" the resulting scripthash is +// b519574e96740a4b3627674a0708e71a73e654a95117fc828b8e177a0579ab42. +func electrumScriptHash(script []byte) string { + digest := sha256.Sum256(script) + for i, j := 0, len(digest)-1; i < j; i, j = i+1, j-1 { + digest[i], digest[j] = digest[j], digest[i] + } + return hex.EncodeToString(digest[:]) +} + +// parseNameScript extracts the name and value from a NAME_UPDATE output +// script. Layout: +// +// OP_NAME_UPDATE OP_2DROP OP_DROP +// +// We only care about the leading push-data pair; the address script +// portion is ignored. +func parseNameScript(script []byte) (name string, value string, err error) { + if len(script) == 0 || script[0] != opNameUpdate { + return "", "", errors.New("nip05namecoin: script is not a NAME_UPDATE") + } + pos := 1 + + nameBytes, next, err := readPushData(script, pos) + if err != nil { + return "", "", err + } + pos = next + + valueBytes, _, err := readPushData(script, pos) + if err != nil { + return "", "", err + } + + return string(nameBytes), string(valueBytes), nil +} + +// readPushData decodes one push-data element starting at `pos` and +// returns the payload bytes and the next read position. +func readPushData(script []byte, pos int) ([]byte, int, error) { + if pos >= len(script) { + return nil, 0, errors.New("nip05namecoin: truncated script") + } + op := script[pos] + + switch { + case op == 0x00: + return []byte{}, pos + 1, nil + + case op < opPushData1: + length := int(op) + end := pos + 1 + length + if end > len(script) { + return nil, 0, errors.New("nip05namecoin: push length exceeds script") + } + return script[pos+1 : end], end, nil + + case op == opPushData1: + if pos+2 > len(script) { + return nil, 0, errors.New("nip05namecoin: truncated OP_PUSHDATA1") + } + length := int(script[pos+1]) + end := pos + 2 + length + if end > len(script) { + return nil, 0, errors.New("nip05namecoin: OP_PUSHDATA1 length exceeds script") + } + return script[pos+2 : end], end, nil + + case op == opPushData2: + if pos+3 > len(script) { + return nil, 0, errors.New("nip05namecoin: truncated OP_PUSHDATA2") + } + length := int(script[pos+1]) | int(script[pos+2])<<8 + end := pos + 3 + length + if end > len(script) { + return nil, 0, errors.New("nip05namecoin: OP_PUSHDATA2 length exceeds script") + } + return script[pos+3 : end], end, nil + + case op == opPushData4: + if pos+5 > len(script) { + return nil, 0, errors.New("nip05namecoin: truncated OP_PUSHDATA4") + } + length := int(script[pos+1]) | + int(script[pos+2])<<8 | + int(script[pos+3])<<16 | + int(script[pos+4])<<24 + end := pos + 5 + length + if end < 0 || end > len(script) { + return nil, 0, errors.New("nip05namecoin: OP_PUSHDATA4 length exceeds script") + } + return script[pos+5 : end], end, nil + + default: + return nil, 0, errors.New("nip05namecoin: unsupported push opcode") + } +} diff --git a/pkg/nip05namecoin/servers.go b/pkg/nip05namecoin/servers.go new file mode 100644 index 0000000..ac5baf5 --- /dev/null +++ b/pkg/nip05namecoin/servers.go @@ -0,0 +1,120 @@ +package nip05namecoin + +// Transport selects how an ElectrumX server is dialed. HAVEN ships +// TCP and TCP+TLS only — WebSocket transports are intentionally +// excluded since HAVEN runs as a server-side process where raw TCP +// is always available. +type Transport int + +const ( + // TransportTCPTLS is raw TCP + TLS with newline-delimited JSON-RPC. + // This is the default and what most Namecoin ElectrumX clients use. + TransportTCPTLS Transport = iota + // TransportTCP is plaintext TCP — rarely used in practice, but + // supported for local testing. + TransportTCP +) + +// ElectrumxServer is a single Namecoin ElectrumX endpoint. +type ElectrumxServer struct { + Host string + Port int + // UseSSL indicates TLS should be negotiated over the raw socket. + // When Transport is the zero value, UseSSL selects between + // TransportTCPTLS (UseSSL=true) and TransportTCP (UseSSL=false). + UseSSL bool + // UsePinnedTrustStore enables the pinned-cert trust anchor bundle + // for servers that present self-signed certificates. This is the + // norm for the public Namecoin ElectrumX ecosystem. + UsePinnedTrustStore bool + // Transport selects the dial transport. Zero value falls back to + // UseSSL: if UseSSL is true it's TCP+TLS, otherwise plain TCP. + Transport Transport +} + +// effectiveTransport resolves the transport to use for a server. +func effectiveTransport(s ElectrumxServer) Transport { + if s.Transport != TransportTCPTLS { + return s.Transport + } + if s.UseSSL { + return TransportTCPTLS + } + return TransportTCP +} + +// DefaultElectrumXServers is the built-in list of public Namecoin +// ElectrumX servers, ordered by operational history. Operators can +// patch this list at compile time if they want to point HAVEN at a +// local Namecoin daemon's ElectrumX bridge or a private mirror. +var DefaultElectrumXServers = []ElectrumxServer{ + {Host: "electrumx.testls.space", Port: 50002, UseSSL: true, UsePinnedTrustStore: true}, + {Host: "nmc2.bitcoins.sk", Port: 57002, UseSSL: true, UsePinnedTrustStore: true}, + {Host: "46.229.238.187", Port: 57002, UseSSL: true, UsePinnedTrustStore: true}, +} + +// PinnedElectrumXCerts is the PEM-encoded bundle of server +// certificates we trust in addition to the system roots. These are +// copied verbatim from the Kotlin Amethyst reference implementation. +// +// To refresh: `echo | openssl s_client -connect HOST:PORT 2>/dev/null | openssl x509 -outform PEM` +// +// The same certificates are served on the WSS endpoints (adjacent +// ports on the same host), so a single pinned bundle covers both +// transports. +var PinnedElectrumXCerts = []string{ + // electrumx.testls.space:50002 — expires 2027-05-04. + // Also covers the i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion:50002 + // hidden service (same operator, same cert). + // SHA-256 fingerprint: + // 53:65:D5:BB:26:19:F5:40:1C:D8:8E:FC:AF:FB:A5:B2:A0:EA:7A:99:2D:F7:0F:05:7E:9B:CD:50:36:C7:79:9C + `-----BEGIN CERTIFICATE----- +MIIDwzCCAqsCFGGKT5mjh7oN98aNyjOCiqafL8VyMA0GCSqGSIb3DQEBCwUAMIGd +MQswCQYDVQQGEwJVUzEQMA4GA1UECAwHQ2hpY2FnbzEQMA4GA1UEBwwHQ2hpY2Fn +bzESMBAGA1UECgwJSW50ZXJuZXRzMQ8wDQYDVQQLDAZJbnRlcncxHjAcBgNVBAMM +FWVsZWN0cnVtLnRlc3Rscy5zcGFjZTElMCMGCSqGSIb3DQEJARYWbWpfZ2lsbF84 +OUBob3RtYWlsLmNvbTAeFw0yMjA1MDUwNjIzNDFaFw0yNzA1MDQwNjIzNDFaMIGd +MQswCQYDVQQGEwJVUzEQMA4GA1UECAwHQ2hpY2FnbzEQMA4GA1UEBwwHQ2hpY2Fn +bzESMBAGA1UECgwJSW50ZXJuZXRzMQ8wDQYDVQQLDAZJbnRlcncxHjAcBgNVBAMM +FWVsZWN0cnVtLnRlc3Rscy5zcGFjZTElMCMGCSqGSIb3DQEJARYWbWpfZ2lsbF84 +OUBob3RtYWlsLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAO4H ++PKCdiiz3jNOA77aAmS2YaU7eOQ8ZGliEVr/PlLcgF5gmthb2DI6iK4KhC1ad34G +1n9IhkXPhkVJ94i8wB3uoTBlA7mI5h59m01yhzSkJAoYoU/i6DM9ipbakqWFCTEp +P+yE216NTU5MbYwThZdRSAIIABe9RyIliMSidyrwHvKBLfnJPFScghW6rhBWN7PG +PA8k0MFGzf+HXbpnV/jAvz08ZC34qiBIjkJrTgh49JweyoZKdppyJcH4UbkslJ2t +YUJR3oURBvrPj+D7TwLVRbX36ul7r4+dP3IjgmljsSAHDK4N/PfWrCBdlj9Pc1Cp +yX+ZDh8X2NrL4ukHoVMCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAeVj6VZNmY/Vb +nhzrC7xBSHqVWQ1wkLOClLsdvgKP8cFFJuUoCMQU5bPMi7nWnkfvvsIKH4Eibk5K +fqiA9jVsY0FHvQ8gP3KMk1LVuUf/sTcRe5itp3guBOSk/zXZUD5tUz/oRk3k+rdc +MsInqhomjNy/dqYmD6Wm4DNPjZh6fWy+AVQKVNOI2t4koaVdpoi8Uv8h4gFGPbdI +sVmtoGiIGkKNIWum+6mnF6PfynNrLk+ztH4TrdacVNeoJUPYEAxOuesWXFy3H4r+ +HKBqA4xAzyjgKLPqoWnjSu7gxj1GIjBhnDxkM6wUOnDq8A0EqxR+A17OcXW9sZ2O +2ZIVwmtnyA== +-----END CERTIFICATE-----`, + + // nmc2.bitcoins.sk:57002 (and 46.229.238.187:57002) — expires 2030-10-22. + `-----BEGIN CERTIFICATE----- +MIID+TCCAuGgAwIBAgIUdmJGukmfPvqmAYpTfuGcjRoYHJ8wDQYJKoZIhvcNAQEL +BQAwgYsxCzAJBgNVBAYTAlNLMREwDwYDVQQIDAhTbG92YWtpYTETMBEGA1UEBwwK +QnJhdGlzbGF2YTEUMBIGA1UECgwLYml0Y29pbnMuc2sxGTAXBgNVBAMMEG5tYzIu +Yml0Y29pbnMuc2sxIzAhBgkqhkiG9w0BCQEWFGRlYWZib3lAY2ljb2xpbmEub3Jn +MB4XDTIwMTAyNDE5MjQzOVoXDTMwMTAyMjE5MjQzOVowgYsxCzAJBgNVBAYTAlNL +MREwDwYDVQQIDAhTbG92YWtpYTETMBEGA1UEBwwKQnJhdGlzbGF2YTEUMBIGA1UE +CgwLYml0Y29pbnMuc2sxGTAXBgNVBAMMEG5tYzIuYml0Y29pbnMuc2sxIzAhBgkq +hkiG9w0BCQEWFGRlYWZib3lAY2ljb2xpbmEub3JnMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAzBUkZNDfaz7kc28l5tDKohJjekWmz1ynzfGx3ZLsqOZE +c+kNfcMaWU+zT/j0mV6pX6KSH7G9pPAku+8PRdKRq+d63wiJDEjGSaFztQWKW6L1 +vTxgCK5gu+Eir3BkTagJObsrLKS+T6qH610/3+btGgoR3lunB5TzCgB/9oQanjDW +zjg2CwmxgR5Iw1Eqfenx7zkSK33FSXSF2SvbUs1Atj2oPU4DLivyrx0RaUmaPemn +cmcpnax+py4pQeB6dJWU1INhzXt3hTJRyoqsSGY3vCECIKIBIkh8GsYjAX4z+Y9y +6pJx0da2b88qPWdsoxaIMvrQiuWknDrSJwAyw2Yd8QIDAQABo1MwUTAdBgNVHQ4E +FgQUT2J83B2/9jxGGdFeWrxMohTzHNwwHwYDVR0jBBgwFoAUT2J83B2/9jxGGdFe +WrxMohTzHNwwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAsbxX +wN8tZaXOybImMZCQS7zfxmKl2IAcqu+R01KPfnIfrFqXPsGDDl3rYLkwh1O4/hYQ +NKNW9KTxoJxuBmAkm7EXQQh1XUUzajdEDqDBVRyvR0Z2MdMYnMSAiiMXMl2wUZnc +QXYftBo0HbtfsaJjImQdDjmlmRPSzE/RW6iUe+1cesKBC7e8nVf69Yu/fxO4m083 +VWwAstlWJfk1GyU7jzVc8svealg/oIiDoOMe6CFSLx1BDv2FeHSpRdqd3fn+AC73 +bK2N2smrHUOQnFijuiFw3WOrjERi0eMhjVNfVu9W9ZYa/Wd6SdIzV55LbG+NpmSf +5W7ix41hRvdT6cTAJA== +-----END CERTIFICATE-----`, +} diff --git a/pkg/nip05namecoin/tls.go b/pkg/nip05namecoin/tls.go new file mode 100644 index 0000000..ecc520f --- /dev/null +++ b/pkg/nip05namecoin/tls.go @@ -0,0 +1,122 @@ +package nip05namecoin + +import ( + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" +) + +// buildPinnedCertPool parses the pinned PEM certs into an *x509.CertPool. +// Malformed entries are skipped so that a bad paste doesn't break the +// whole bundle. +func buildPinnedCertPool() *x509.CertPool { + pool := x509.NewCertPool() + for _, pemBlock := range PinnedElectrumXCerts { + block, _ := pem.Decode([]byte(pemBlock)) + if block == nil { + continue + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + continue + } + pool.AddCert(cert) + } + return pool +} + +// pinnedFingerprints returns the SHA-256 fingerprints of the pinned +// certificates. We use this as an additional match path in the custom +// VerifyPeerCertificate callback so that cert-chain mismatches from the +// standard library still succeed when the operator publishes an updated +// cert with a known fingerprint. +func pinnedFingerprints() [][32]byte { + out := make([][32]byte, 0, len(PinnedElectrumXCerts)) + for _, pemBlock := range PinnedElectrumXCerts { + block, _ := pem.Decode([]byte(pemBlock)) + if block == nil { + continue + } + out = append(out, sha256.Sum256(block.Bytes)) + } + return out +} + +// tlsConfigFor returns a *tls.Config suitable for an ElectrumX connection +// to the given server. When UsePinnedTrustStore is false, the returned +// config is the stdlib default (system roots only). When true, the config +// trusts either the system roots *or* one of the pinned self-signed certs. +func tlsConfigFor(server ElectrumxServer) *tls.Config { + if !server.UsePinnedTrustStore { + return &tls.Config{ + ServerName: server.Host, + MinVersion: tls.VersionTLS12, + } + } + + pinnedPool := buildPinnedCertPool() + fingerprints := pinnedFingerprints() + + cfg := &tls.Config{ + ServerName: server.Host, + MinVersion: tls.VersionTLS12, + // InsecureSkipVerify disables the default chain verification. We + // run our own in VerifyPeerCertificate below — trying the system + // roots first, then the pinned bundle, then raw SHA-256 match. + // This is NOT a trust-all: the handshake still fails unless at + // least one of those paths succeeds. + InsecureSkipVerify: true, + VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error { + if len(rawCerts) == 0 { + return errors.New("nip05namecoin: no peer certificates presented") + } + certs := make([]*x509.Certificate, 0, len(rawCerts)) + for _, raw := range rawCerts { + c, err := x509.ParseCertificate(raw) + if err != nil { + return fmt.Errorf("nip05namecoin: parse peer cert: %w", err) + } + certs = append(certs, c) + } + leaf := certs[0] + intermediates := x509.NewCertPool() + for _, c := range certs[1:] { + intermediates.AddCert(c) + } + + // 1. Try the system trust store with proper hostname binding. + if _, err := leaf.Verify(x509.VerifyOptions{ + DNSName: server.Host, + Intermediates: intermediates, + }); err == nil { + return nil + } + + // 2. Try the pinned pool. The pinned certs are self-signed, so + // we let them act as their own root. Hostname match is + // intentionally not required here — some operators share a + // cert across multiple hostnames. + if _, err := leaf.Verify(x509.VerifyOptions{ + Roots: pinnedPool, + Intermediates: intermediates, + }); err == nil { + return nil + } + + // 3. Last-chance: raw SHA-256 fingerprint match. + leafFP := sha256.Sum256(leaf.Raw) + for _, fp := range fingerprints { + if fp == leafFP { + return nil + } + } + + return fmt.Errorf("nip05namecoin: peer cert for %s is not in system or pinned trust store", server.Host) + }, + } + + return cfg +} diff --git a/pkg/nip05namecoin/whitelist.go b/pkg/nip05namecoin/whitelist.go new file mode 100644 index 0000000..ccc0b89 --- /dev/null +++ b/pkg/nip05namecoin/whitelist.go @@ -0,0 +1,118 @@ +package nip05namecoin + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "strings" + "time" + + "github.com/nbd-wtf/go-nostr" +) + +// ResolverFunc is the resolver signature consumed by +// ResolveNamesToPubkeys. Extracted so callers (and tests) can swap in +// a fake. The default production resolver is QueryIdentifier. +type ResolverFunc func(ctx context.Context, identifier string) (*nostr.ProfilePointer, error) + +// Logger is the minimal logging surface used by ResolveNamesToPubkeys. +// HAVEN's main package wires its standard logger in; tests use a +// no-op. +type Logger interface { + Printf(format string, args ...any) +} + +// ResolveOptions tunes the startup resolution pass. Zero values are +// sensible defaults. +type ResolveOptions struct { + // PerNameTimeout bounds each individual Namecoin lookup. Defaults + // to 30s when zero. + PerNameTimeout time.Duration + // Resolver overrides the default QueryIdentifier. Useful for + // tests; production code can leave this nil. + Resolver ResolverFunc + // Logger overrides the destination for status logs. nil means + // "discard". + Logger Logger +} + +type discardLogger struct{} + +func (discardLogger) Printf(string, ...any) {} + +// ResolveNamesToPubkeys resolves each .bit / d/ / id/ identifier in +// `names` to its underlying hex pubkey and writes the result into +// `target`. Failures are logged and skipped — they never abort the +// caller. The same target map can be passed across multiple calls; +// existing entries are preserved. +func ResolveNamesToPubkeys(target map[string]struct{}, names map[string]struct{}, opts ResolveOptions) { + if len(names) == 0 { + return + } + if target == nil { + return + } + timeout := opts.PerNameTimeout + if timeout == 0 { + timeout = 30 * time.Second + } + resolver := opts.Resolver + if resolver == nil { + resolver = QueryIdentifier + } + logger := opts.Logger + if logger == nil { + logger = discardLogger{} + } + + logger.Printf("resolving %d Namecoin (.bit) whitelist name(s) via ElectrumX", len(names)) + for name := range names { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + pp, err := resolver(ctx, name) + cancel() + if err != nil { + logger.Printf("WARN: failed to resolve Namecoin name %q: %v", name, err) + continue + } + if pp == nil || pp.PublicKey == "" { + logger.Printf("WARN: Namecoin name %q resolved to empty pubkey", name) + continue + } + target[pp.PublicKey] = struct{}{} + logger.Printf("resolved Namecoin name %q to pubkey %s", name, pp.PublicKey) + } +} + +// LoadNamesFile reads a JSON array of Namecoin identifiers from +// `filePath` and returns them as a set. Whitespace is trimmed and +// empty entries dropped. Returns an empty (non-nil) map when +// filePath == "". +func LoadNamesFile(filePath string) (map[string]struct{}, error) { + names := map[string]struct{}{} + if filePath == "" { + return names, nil + } + file, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("nip05namecoin: read names file: %w", err) + } + var list []string + if err := json.Unmarshal(file, &list); err != nil { + return nil, fmt.Errorf("nip05namecoin: parse names file: %w", err) + } + for _, name := range list { + name = strings.TrimSpace(name) + if name == "" { + continue + } + names[name] = struct{}{} + } + return names, nil +} + +// ErrEmptyPubkey is returned when a resolver succeeds but yields an +// empty PublicKey. Exposed mostly for tests; production callers treat +// it the same as any other resolution failure. +var ErrEmptyPubkey = errors.New("nip05namecoin: resolver returned empty pubkey") diff --git a/pkg/nip05namecoin/whitelist_test.go b/pkg/nip05namecoin/whitelist_test.go new file mode 100644 index 0000000..b370349 --- /dev/null +++ b/pkg/nip05namecoin/whitelist_test.go @@ -0,0 +1,164 @@ +package nip05namecoin + +import ( + "context" + "errors" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/nbd-wtf/go-nostr" +) + +func TestLoadNamesFile_Empty(t *testing.T) { + names, err := LoadNamesFile("") + if err != nil { + t.Fatal(err) + } + if len(names) != 0 { + t.Errorf("expected empty map for empty path, got %v", names) + } +} + +func TestLoadNamesFile_Parses(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "names.json") + body := `["me@me.bit", "d/example", " id/alice ", ""]` + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + + names, err := LoadNamesFile(path) + if err != nil { + t.Fatal(err) + } + want := []string{"me@me.bit", "d/example", "id/alice"} + if len(names) != len(want) { + t.Fatalf("got %d entries, want %d (%v)", len(names), len(want), names) + } + for _, w := range want { + if _, ok := names[w]; !ok { + t.Errorf("missing entry %q in %v", w, names) + } + } +} + +func TestLoadNamesFile_Malformed(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "names.json") + if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := LoadNamesFile(path); err == nil { + t.Error("expected error for malformed JSON") + } +} + +func TestLoadNamesFile_Missing(t *testing.T) { + if _, err := LoadNamesFile("/nonexistent/path/should/not/exist.json"); err == nil { + t.Error("expected error for missing file") + } +} + +// captureLogger records Printf calls so tests can assert on log output. +type captureLogger struct { + mu sync.Mutex + lines []string +} + +func (l *captureLogger) Printf(format string, args ...any) { + l.mu.Lock() + defer l.mu.Unlock() + // We don't care about the formatted output here, only the count; + // the format string itself is descriptive enough. + _ = format + _ = args + l.lines = append(l.lines, format) +} + +func TestResolveNamesToPubkeys_SuccessAndFailure(t *testing.T) { + target := map[string]struct{}{ + "existing-pubkey": {}, + } + names := map[string]struct{}{ + "me@example.bit": {}, + "broken@broken.bit": {}, + } + resolved := "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9" + fake := func(_ context.Context, identifier string) (*nostr.ProfilePointer, error) { + switch identifier { + case "me@example.bit": + return &nostr.ProfilePointer{PublicKey: resolved}, nil + case "broken@broken.bit": + return nil, errors.New("transport failure") + default: + return nil, errors.New("unexpected: " + identifier) + } + } + logger := &captureLogger{} + + ResolveNamesToPubkeys(target, names, ResolveOptions{ + Resolver: fake, + Logger: logger, + }) + + if _, ok := target[resolved]; !ok { + t.Errorf("expected resolved pubkey %s in target, got %v", resolved, target) + } + if _, ok := target["existing-pubkey"]; !ok { + t.Errorf("existing entries should be preserved, got %v", target) + } + if len(target) != 2 { + t.Errorf("expected exactly 2 entries (existing + resolved), got %d: %v", len(target), target) + } + + // We expect a "resolving N names" line + a warning + a success line. + if len(logger.lines) < 3 { + t.Errorf("expected at least 3 log lines, got %d: %v", len(logger.lines), logger.lines) + } +} + +func TestResolveNamesToPubkeys_NoNames(t *testing.T) { + target := map[string]struct{}{"existing": {}} + called := false + fake := func(_ context.Context, _ string) (*nostr.ProfilePointer, error) { + called = true + return nil, nil + } + ResolveNamesToPubkeys(target, map[string]struct{}{}, ResolveOptions{Resolver: fake}) + if called { + t.Error("resolver should not be called when no names are configured") + } + if _, ok := target["existing"]; !ok { + t.Error("existing target entries should be preserved") + } +} + +func TestResolveNamesToPubkeys_EmptyPubkeyIgnored(t *testing.T) { + target := map[string]struct{}{} + names := map[string]struct{}{"empty@empty.bit": {}} + fake := func(_ context.Context, _ string) (*nostr.ProfilePointer, error) { + return &nostr.ProfilePointer{PublicKey: ""}, nil + } + ResolveNamesToPubkeys(target, names, ResolveOptions{Resolver: fake}) + if len(target) != 0 { + t.Errorf("expected empty target when resolver returns empty pubkey, got %v", target) + } +} + +func TestResolveNamesToPubkeys_NilTarget(t *testing.T) { + // A nil target is a no-op (defensive): the helper just returns. + // This guards against a panic in case the caller forgets to + // initialise the whitelist map. + names := map[string]struct{}{"me@me.bit": {}} + called := false + fake := func(_ context.Context, _ string) (*nostr.ProfilePointer, error) { + called = true + return &nostr.ProfilePointer{PublicKey: "x"}, nil + } + ResolveNamesToPubkeys(nil, names, ResolveOptions{Resolver: fake}) + if called { + t.Error("resolver should not be called when target is nil") + } +} diff --git a/whitelisted_namecoin_names.example.json b/whitelisted_namecoin_names.example.json new file mode 100644 index 0000000..132cfb4 --- /dev/null +++ b/whitelisted_namecoin_names.example.json @@ -0,0 +1,5 @@ +[ + "me@me.bit", + "d/example", + "id/alice" +]