Skip to content

feat(relay): Namecoin .bit relay resolver + TLSA pin records#44

Merged
ethicnology merged 2 commits into
ethicnology:feat/namecoinfrom
mstrofnone:feat/relay-namecoin
May 16, 2026
Merged

feat(relay): Namecoin .bit relay resolver + TLSA pin records#44
ethicnology merged 2 commits into
ethicnology:feat/namecoinfrom
mstrofnone:feat/relay-namecoin

Conversation

@mstrofnone

Copy link
Copy Markdown

Summary

Adds a resolver-only library for .bit Nostr relays:

  • wss://example.bit/ → real wss://... URL via Namecoin d/example lookup
  • Multi-label wss://relay.example.bit/ → walks d/example's map.relay per ifa-0001, never queries d/relay.example
  • Exposes RFC 6698 TLSA pin records from the same record so callers can pin the TLS handshake
  • Surfaces tor / _tor.txt .onion endpoints so Tor-aware callers can route through them

This is the second half of the Namecoin .bit Nostr support; the first half (NIP-05 identity) is in #43 and lands its shared transport. Both PRs are independently reviewable but PR 2 depends on PR 1's ElectrumxClient / DefaultElectrumxClient types — please merge PR 1 first.

⚠️ Stacking note: This branch is built on top of feat/nip05-namecoin from #43. The diff against develop includes both PRs; if you'd like, I can rebase onto develop after #43 merges (or you can squash-merge #43 first, then rebase this).

API surface

class NamecoinRelayResolver {
  static bool isBitUrl(String wssUrl);
  Future<RelayResolution?> resolve(String wssUrl);
  void invalidate(String host);
  void clear();
}

class RelayResolution {
  final String canonicalUrl;        // wss://relay.example.bit/  (identity)
  final String? clearnetUrl;        // wss://real.host/          (wire)
  final List<String> candidates;
  final List<TlsaRecord> tlsaRecords;
  final List<String> onionEndpoints;
}

class TlsaRecord {
  bool matchesCertificate(List<int> certDer);
  bool matchesSpki(List<int> spkiDer);
  bool get requiresPkixValidation;
  // plus full RFC 6698 enums: TlsaUsage, TlsaSelector, TlsaMatchingType
}

The intended host-app integration:

final resolver = NamecoinRelayResolver(client: myElectrumxClient);
final r = await resolver.resolve(userTypedUrl);
final wireUrl = r?.clearnetUrl ?? userTypedUrl;

final httpClient = HttpClient();
if (r?.tlsaRecords.isNotEmpty ?? false) {
  httpClient.badCertificateCallback = (cert, host, port) {
    return r!.tlsaRecords.any((t) => t.matchesCertificate(cert.der));
  };
}

final ws = await WebSocket.connect(wireUrl, customClient: httpClient);

What it does NOT do

This is a resolver only. It deliberately doesn't:

  • Open WebSockets — host wires the returned URL into their own client.
  • Mutate HttpClient's badCertificateCallback — host layers TLSA on top in a portable way.
  • Decide whether to prefer Tor — host's job, based on user settings.

The split is so the library stays portable across VM/web/Flutter targets where the TLS stack differs, while the host app (this repo's dispute companion, or any other consumer) wires the platform-specific bits.

Spec safety properties (all tested)

Property Test
No ancestor inheritance of relay / tls / tor parseRelayUrls subdomain walk: ancestor relay does NOT leak (and the same for tlsa and tor)
d/<sub>.<parent> never queried subdomain trap: never queries d/sub.parent — explicitly verifies only d/parent was queried
Multi-label .onion REJECTED parseTorEndpoints multi-label .onion REJECTED
Non-onion under tor/_tor REJECTED parseTorEndpoints non-onion strings dropped
Tolerant base64 for TLSA tolerates unpadded base64, tolerates url-safe alphabet, strips whitespace inside base64
Malformed records skipped, not crashed parseTlsaRecords skips malformed entries

Tests

77 new test cases across 3 files:

File Cases What it covers
test/namecoin/tlsa_test.dart 19 tryParse shape/range/format/base64 quirks; matching matrix across every selector × matchingType combo; requiresPkixValidation across all 4 usages
test/namecoin/record_parser_test.dart 39 parseHostFlat, walkSubdomain (exact/wildcard/empty-key/anti-inheritance), parseRelayUrls, parseTlsaRecords, parseTorEndpoints
test/namecoin/relay_resolver_test.dart 19 isBitUrl; mock resolution; path preservation; TLSA + Tor passthrough; positive + negative caching; subdomain-trap regression; 3 live-Namecoin blockchain smoke tests against testls.bit

526/526 tests pass (449 baseline + 77 new). Run with:

dart test test/namecoin/

Live verification

Captured against the real Namecoin blockchain just before opening this PR:

$ dart run .scratch_relay_verify.dart
--- wss://testls.bit/ ---
  (no resolution)              # root has nostr.names but no relay field — correct
--- wss://relay.testls.bit/ ---
  canonical:  wss://relay.testls.bit/
  clearnet:   wss://relay.testls.bit/
  candidates: [wss://relay.testls.bit/]
  tlsa:       1 record(s)
    - daneTa/subjectPublicKeyInfo/sha256: m14YT5aSELhdDbZXOKFcSj2kK59XzV5lkiUlElBZh4A=
  onion:      [ws://6cbn4rskfdr647otej7gpqlmpqcmj723vg2eoeuu7ljbwu6cpdebozyd.onion/]
--- wss://definitely_does_not_exist_xyz_qux.bit/ ---
  (no resolution)

The relay.testls.bit resolution exercises the full pipeline: subdomain split → d/testls ElectrumX lookup → map.relay walk → relay extraction → TLSA decode → Tor extraction. Same record, same fields the Kotlin Amethyst port reads via PR #2595.

Implementation

5 new files under lib/src/namecoin/:

File Purpose
tlsa.dart RFC 6698 enums + TlsaRecord + tolerant base64 decoder
record_parser.dart parseHostFlat, walkSubdomain, parseRelayUrls, parseTlsaRecords, parseTorEndpoints
relay_resolver.dart Public NamecoinRelayResolver + cache

Plus lib/nostr.dart exports.

Reuses the ElectrumxClient / DefaultElectrumxClient transport from #43 unchanged — identity + relay resolution share one ElectrumX round-trip per host when a single client is reused.

Back-compat

  • No breaking changes. NIP-05 path from feat(nip05): Namecoin .bit identifier resolution via ElectrumX #43 untouched.
  • All new types are additive: NamecoinRelayResolver, RelayResolution, TlsaRecord, TlsaUsage, TlsaSelector, TlsaMatchingType, ParsedHostFlat, parseHostFlat, walkSubdomain, parseRelayUrls, parseTlsaRecords, parseTorEndpoints.
  • No new mandatory dependencies. SHA-512 uses pointycastle (already a dep).
  • Existing 449 tests still pass without modification.

References

Checklist

  • dart analyze — no issues
  • dart format — formatted
  • dart test — 526/526 pass
  • Live-verified on Namecoin chain (relay.testls.bit resolves to real wss + TLSA + onion)
  • No new mandatory dependencies
  • Public APIs documented (dartdoc on every method)
  • Anti-inheritance, subdomain-trap, multi-label-onion security properties tested
  • RFC 6698 matching matrix exhaustively tested

Adds NIP-05 resolution backed by Namecoin .bit names — the cypherpunk
twin of DNS-based NIP-05.

API mirrors DnsIdentifier 1:1 so callers don't have to branch:

  NamecoinIdentifier.isBit(id)
  NamecoinIdentifier.fetch(id, {client})
  NamecoinIdentifier.verify(identifier, pubkey, {client})
  NamecoinIdentifier.lookupUri(id)

  NostrIdentifier.fetch(id, {client})    // dispatches by shape
  NostrIdentifier.verify(...)

Accepted shapes (case-insensitive, optional nostr: URI prefix):

  - alice@example.bit
  - example.bit
  - d/<name>
  - id/<name>

Both name-value forms are supported:

  - simple:    "nostr": "hex-pubkey"
  - extended:  "nostr": { "names": {...}, "relays": {...} }

Implementation
--------------

- pure Dart, no new mandatory dependencies
- WebSocket-over-TLS transport via dart:io WebSocket.connect, with
  pluggable HttpClient for pinned-cert trust
- failover across 3 default servers; definitive misses short-circuit
- name-script parsing handles direct push / OP_PUSHDATA1 / OP_PUSHDATA2
- expiry check at NameExpireDepth = 36000 blocks (~250 days)

Ported from the canonical Go reference at
mstrofnone/nostrlib-nip05-namecoin and the JS twin in nostr-tools
PR #533. Behaviour matches the Kotlin Amethyst port.

Tests
-----

29 new test cases in test/nips/nip_005_namecoin_test.dart:

  - isBit / parseIdentifier shape detection (10 cases)
  - JSON value extraction across simple, extended, and id/ shapes (8)
  - script utilities incl. PUSHDATA1/2 round-trips (5)
  - lookupUri (2)
  - 3 live-Namecoin blockchain smoke tests (m@testls.bit,
    testls.bit, non-existent .bit) — same pattern as the existing
    damus@damus.io live-DNS tests in nip_005_test.dart

449/449 tests pass (420 existing + 29 new).

Live-verified on the real Namecoin blockchain via ElectrumX:

  m@testls.bit  → 6cdebccabda1dfa058ab85352a79509b592b2bdfa0370325e28ec1cb4f18667d
  testls.bit    → 460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c
  nonexistent   → null

Back-compat
-----------

- No breaking changes. Existing DnsIdentifier (Nip5) untouched.
- New types are additive: NamecoinIdentifier, NostrIdentifier,
  ElectrumxClient, DefaultElectrumxClient, ElectrumxServer,
  defaultElectrumxServers, NameNotFoundException,
  NameExpiredException, ElectrumxUnreachableException.
- web_socket_channel was already a transitive dep (used by nip_046);
  this PR uses dart:io WebSocket directly so no new dependency lines
  in pubspec.yaml.

References
----------

  - Go:     mstrofnone/nostrlib-nip05-namecoin
  - JS:     nbd-wtf/nostr-tools PR #533
  - Kotlin: vitorpamplona/amethyst PRs #1547 / #2595 / #2612
  - Swift:  nostur-com/nostur-ios-public PR #60
  - Spec:   namecoin/proposals ifa-0001
Adds resolver-only support for `wss://example.bit/` Nostr relay URLs:
look up the Namecoin record, walk `map` for subdomains, return the
real `wss://...` URL plus any TLSA pin records and Tor aliases the
record advertises.

This is the second half of the Namecoin .bit Nostr support. The first
half (NIP-05 identity resolution) is in the parent commit
0cf86b7.

API surface
-----------

  class NamecoinRelayResolver {
    static bool isBitUrl(String wssUrl);
    Future<RelayResolution?> resolve(String wssUrl);
    void invalidate(String host);
    void clear();
  }

  class RelayResolution {
    final String canonicalUrl;        // wss://relay.example.bit/
    final String? clearnetUrl;        // wss://real.host/ (or null for onion-only)
    final List<String> candidates;
    final List<TlsaRecord> tlsaRecords;
    final List<String> onionEndpoints;
  }

  class TlsaRecord {
    bool matchesCertificate(List<int> certDer);
    bool matchesSpki(List<int> spkiDer);
    bool get requiresPkixValidation;
    // plus full RFC 6698 enums: TlsaUsage, TlsaSelector, TlsaMatchingType
  }

What it does
------------

Given `wss://relay.testls.bit/`:

  1. Splits the host into `(d/testls, ["relay"])` per ifa-0001 §map.
     ONE ElectrumX call regardless of subdomain depth.
  2. Walks `d/testls`'s value JSON: `map.relay` (exact > * > null,
     honouring the "" empty-key default rule).
  3. Returns:
     - candidates  - all ws[s]:// URLs from `relay` / `relays` /
                      `nostr.relay` / `nostr.relays` / pubkey-keyed
                      `nostr.relays[<pk>]` (in that priority order)
     - tlsaRecords - 4-element `[usage, selector, matchingType, base64]`
                      arrays from the same node's `tls` field
     - onionEndpoints - `tor` (string or array) AND `_tor.txt` shapes;
                      bare onions promoted to `ws://x.onion/`

What it does NOT do
-------------------

This is a **resolver only**. It does NOT:

  - Open WebSockets (host wires the returned URL into their own client)
  - Mutate `HttpClient.badCertificateCallback` (host layers TLSA on top)
  - Decide whether to prefer Tor (host's job, based on user settings)

Spec safety properties (all tested)
-----------------------------------

  - **No ancestor inheritance** of `relay` / `tls` / `tor`. A
    subdomain only gets what its walked node declares, NEVER what an
    ancestor `d/parent` declared. Otherwise a parent could silently
    authorise a subdomain it doesn't control.
  - **`d/<sub>.<parent>` is never queried.** Multi-label hosts always
    walk the parent's `map` tree. (Tested with subdomain-trap
    regression test.)
  - **Multi-label .onion REJECTED.** `evil.legit.onion` cannot
    appear under `tor` / `_tor` so a record can't redirect to a
    subdomain of someone else's hidden service.
  - **Non-onion under `tor` / `_tor` REJECTED.** Tor field can't
    redirect to clearnet.
  - **TLSA malformed records skipped, not crashed.** Wrong arity,
    non-base64, out-of-range codes all fall through.
  - **Tolerant base64 decoding** for TLSA: handles whitespace,
    unpadded, and url-safe alphabet (matches the Kotlin reference).

Tests
-----

77 new test cases across 3 files:

  - test/namecoin/tlsa_test.dart           19 cases
    * tryParse: arity, range, format, whitespace, padding, url-safe
    * matching matrix: every selector × matchingType combo
    * requiresPkixValidation across all 4 usages
  - test/namecoin/record_parser_test.dart  39 cases
    * parseHostFlat: bare, subdomain, multi-label, edge cases
    * walkSubdomain: exact, wildcard, empty-key, anti-inheritance
    * parseRelayUrls: priority, dedup, subdomain walk, scheme filter
    * parseTlsaRecords: subdomain walk, malformed skip
    * parseTorEndpoints: string, array, _tor.txt, multi-label reject
  - test/namecoin/relay_resolver_test.dart 19 cases
    * isBitUrl, mock resolution, path preservation, TLSA passthrough
    * Tor passthrough, caching (positive + negative), invalidate
    * subdomain-trap regression
    * 3 live-Namecoin blockchain smoke tests against testls.bit

526/526 tests pass (449 baseline + 77 new).

Live verification
-----------------

Captured against the real Namecoin blockchain just before opening
this PR:

  --- wss://relay.testls.bit/ ---
    canonical:  wss://relay.testls.bit/
    clearnet:   wss://relay.testls.bit/
    candidates: [wss://relay.testls.bit/]
    tlsa:       1 record(s)
      - daneTa/subjectPublicKeyInfo/sha256: m14YT5aSELhdDbZXOKFcSj2kK59XzV5lkiUlElBZh4A=
    onion:      [ws://6cbn4rskfdr647otej7gpqlmpqcmj723vg2eoeuu7ljbwu6cpdebozyd.onion/]

That hits the same Namecoin record that the Kotlin Amethyst,
Swift Nostur, JS nostr-tools, and Go nostrlib-nip05-namecoin ports
all hit, with byte-for-byte agreement on the resolved fields.

Back-compat
-----------

- No breaking changes. NIP-05 path from the previous commit untouched.
- All new types are additive: NamecoinRelayResolver, RelayResolution,
  TlsaRecord, TlsaUsage, TlsaSelector, TlsaMatchingType,
  ParsedHostFlat, parseHostFlat, walkSubdomain, parseRelayUrls,
  parseTlsaRecords, parseTorEndpoints.
- Reuses the ElectrumxClient + DefaultElectrumxClient transport from
  the NIP-05 PR, so identity + relay resolution share one ElectrumX
  round-trip per host.

References
----------

  - Spec:   namecoin/proposals ifa-0001 §map / §tls
  - RFC:    RFC 6698 (TLSA)
  - Kotlin: vitorpamplona/amethyst PR #2595 (the canonical reference)
  - Issue:  PR #2612 (multi-label map walk for NIP-05)
@ethicnology

Copy link
Copy Markdown
Owner

Hey you can try to resolve alice and nostr/bob on namecoin

Alice should have ifa-0001 like
Bob should have a NIP05 like

This might interest you as well:

@ethicnology ethicnology changed the base branch from develop to feat/namecoin May 16, 2026 10:57
@ethicnology ethicnology merged commit 2ab516b into ethicnology:feat/namecoin May 16, 2026
@ethicnology

Copy link
Copy Markdown
Owner

@mstrofnone love the work. Tested PR-44 end to end against my own Namecoin ElectrumX (electrum.nmc.ethicnology.com:50004) and it resolves correctly:

bob@ethicnology.bit   => c6d6a2a20dddcbf988ca1fb23907b6e27da98365ed38ce642f29147ebb5c58cf
                         relays=[wss://nos.lol, wss://relay.nostr.bg, wss://nostr.wine]
bobot@ethicnology.bit => 98105ee46e8702d1d19e9140ee92eb15c7f083c9e763f98d908ca8c0b2812f05
unknown@ethicnology.bit => null

Scripthash, name_show, extended-form names+relays parsing, defensive handling on malformed values — all correct. Real work, real result.

I've merged this into an integration branch (feat/namecoin) rather than develop so we can shape it together before it hits the main line. A few things I'd like us to discuss / iterate on there:

  1. Scope. .bit. To avoid squatters I would like to open search to any subspace. PR-44 adds Bitcoin script parsing, DANE/TLSA, Tor handling. I'd like to think about which bits belong in nostr core and which fit better in a sibling package you'd own (nostr_namecoin_electrumx or similar).

  2. Transport options. ElectrumX has 4 schemes (tcp / ssl / ws / wss). PR-44 picks WSS but that locks out the default Namecoin ElectrumX deployments (my own server didn't have WSS until I added it). Per-platform transport variants could be neat.

Do you want to collaborate on this branch ?

@mstrofnone

Copy link
Copy Markdown
Author

Hi @ethicnology!

For 1. it's a problem but I like the rationale here https://www.namecoin.org/docs/faq/#is-squatting-a-problem--what-can-be-done-about-it and having a/ethic, b/ethic, c/ethic or any other scheme is just confusing for end users. I recently found this https://github.com/btcjt/titan/blob/main/docs/name-protocol.md and https://njump.me/nevent1qqsq0dg65gs2kpln4rghlzcskq2tnw4cksyhhn2g0nllgzk47dhahhqprdmhxue69uhhyetvv9ujuam9wd6x2unwvf6xxtnrdakj7q3qpc57ls4rad5kvsp733suhzl2d4u9y7h4upt952a2pucnalc59teqxpqqqqqqz7tfg4z there isn't a way to avoid this race and namecoin has been around since 2011 and for cents you can register a name.

  1. seems neat but I don't have any special insights. There are 2 nips which I think you could update or contribute to if you have per-platform transports carved out or any other ideas RFC: Namecoin-track NIPs (NIP-05 over .bit, relay discovery, TLSA pinning, Tor routing) nostr-protocol/nips#2330 and RFC: Human-readable naming for NIP-5A nsites — comparing two existing chain-anchored designs nostr-protocol/nips#2348 so that other clients don't have to recreate the wheel.

I also sent you a couple of DMs on matrix but the meat of it is can you add your electrumx to amethyst as a PR?

mstrofnone pushed a commit to mstrofnone/nips that referenced this pull request May 16, 2026
ethicnology/dart-nostr#44 merged 2026-05-16. First Dart impl and the
first non-Amethyst impl to ship the full N1 + N2 + N3 surface
(NIP-05 over .bit, relay subdomain resolver, TLSA pin records) in
one library. 526/526 tests pass including live-blockchain smoke
tests against testls.bit with byte-for-byte agreement against the
Kotlin / Swift / TypeScript / Go ports on the same on-chain records.

- README.md: add to the per-language reference implementations list.
- N3.md: add to the per-language TLSA mirror enumeration.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants