sirius/docs/DESIGN-signed-records-manifest.md
Silent Mode fb3b36f168 feat(theseus): a subdomain rule applies wherever the name is served from
The gateway checks a name's host rules before it decides what to serve, so a
blocked or redirected subdomain behaves the same whatever record the name
carries. Theseus only inherited that for names it proxies through the
gateway's /bns/ mount. A name with both s3 and ip — the shape that caused
the 2026-08-13 subdomain bug — would have had its blocked subdomain answer
anyway, because Theseus talks straight to the IP.

It now asks the gateway for the host's verified rule before taking either of
the paths it serves itself, and only for those paths, so an ordinary
subdomain navigation gains no round trip. Verification stays in one place:
the client reads a decision, it does not re-derive one.

The spec catches up with what is implemented — it still described v1 and
called hosts a future idea.
2026-09-28 01:25:22 +02:00

9.7 KiB

Signed records manifest (BNS records v1)

Status: shipped v2 — gateway GET/POST /api/records/<name> and verified GET /api/dns/<name> live on silentmode.st/navigate.st; owner-tracking in the indexer live; portal DNS editor + wallet-side signMessage + signRecordsManifest shipped. Native DNS server (UDP :53 → BCDN name → verified records) is a v2 follow-up. Related: DESIGN-tld-registry.md, PROTOCOL.md, public explainer at silentmode.st/sirius-x/docs/#signed-records. Mirrored to site-sirius-x/docs/DESIGN-signed-records-manifest.md so it rides the next push-split to the sirius forge.

Problem

Every records edit today is a chain UPD:

  • costs ~1,300 sat + service fee
  • caps records at ~150 bytes of payload
  • rules out DNS-style records (A/AAAA/MX/TXT/CNAME/NS) at any useful scale
  • makes TXT-verification churn (ACME, DKIM, DMARC) actively expensive

We want records to be:

  • free to edit — no chain tx per change
  • unlimited size — accommodate whole DNS record sets
  • trustless — no operator can substitute their own records for the owner's
  • backwards compatible — existing on-chain h / s3 / p / u records keep working

Design

The chain says who owns the name. A signed manifest says what the owner's records are. Resolvers verify both.

The manifest — _records.json

Lives at the Sia bucket the name's on-chain records.s3 already points at, under the fixed filename _records.json. Shape:

{
  "v": 1,
  "name": "bitcoin.cash",
  "seq": 42,
  "updated_at": "2026-09-12T00:00:00Z",
  "dns": {
    "A":     ["1.2.3.4"],
    "AAAA":  ["2001:db8::1"],
    "MX":    [{"pref": 10, "host": "mail.example.com"}],
    "TXT":   ["v=spf1 include:_spf.silentmode.st -all"],
    "CNAME": null,
    "NS":    []
  },
  "meta":  { "note": "optional free-form" },
  "sig":   "H3n… (BCH message signature, base64)"
}

Rules:

  • v: schema version, currently 1.
  • name: the fully-qualified name the manifest belongs to. Resolver rejects a manifest whose name field doesn't match the URL it was fetched from.
  • seq: monotonic integer. Resolvers cache the highest seen; blobs with an older or equal seq are rejected as replays.
  • updated_at: ISO-8601 UTC. Human-readable only — verification is on seq.
  • dns.*: DNS record slots. null or [] means "not set". A/AAAA/TXT are string arrays. MX is objects {pref, host}. NS is a string array of host names. CAA and SRV are accepted too.
  • hosts: optional, v2 — per-subdomain routing and records. See below.
  • meta: unstructured; ignored by DNS resolvers, useful for portal UI.
  • sig: BCH-style message signature (same format libauth.signMessage produces) over the canonical bytes of the envelope (see below).

v2 — per-host entries (hosts)

v1 described the apex and nothing else. A name's subdomains served the folder their labels spelled out, with no way to change it and no way to give one its own records. v2 adds one optional block:

"hosts": {
  "www":   { "path": "" },
  "shop":  { "path": "store/v2" },
  "blog":  { "u": "https://blog.example.com" },
  "old":   { "block": true },
  "mail":  { "dns": { "A": ["5.6.7.8"] } },
  "*":     { "u": "https://example.x/" }
}

Keys are the labels ABOVE the registered name, in DNS order: www, a.b, or * for everything not named explicitly. Exact match wins over the wildcard, so a named host opts out of a blanket rule. The apex is never a key — its content comes from the chain, and letting an off-chain blob redirect the main site would move the name's trust boundary without the owner spending a transaction. At most 200 hosts, at most 8 labels deep.

Fields, any combination:

  • path — the folder inside the bucket this host serves, replacing the derived one. "" means the bucket root, which is how www is made to mirror the apex. Relative, no leading /, no ...
  • u — redirect the host to this URL (302). http(s) only, ≤2048 chars.
  • block — this host returns 404 even if a folder of that name exists.
  • dns — the same slots as the apex dns, for this host.

Precedence within an entry: block > u > path. The most restrictive intent wins, so a host that is somehow both blocked and redirected stays blocked.

The default when no entry matches is unchanged and needs no manifest at all: the labels above the name, REVERSED, become the folder path — DNS reads most specific first, a path reads least specific first, so chipnet.explorer.aegis.x and aegis.x/explorer/chipnet/ are the same page.

DNS inheritance. A host with its own dns block owns its record set outright — www does not inherit the apex's MX, the way classic DNS works. A host without one inherits the apex, the way everything else on BNS does. GET /api/dns/<host> applies this and reports which host it answered for.

Compatibility. A v1 manifest stays valid forever and needs no republishing; it simply has no hosts. A manifest that fails to verify is treated exactly like no manifest — the name keeps serving from its chain records rather than failing shut.

Where it is enforced. lib/manifest.js (verifyManifest, validateHosts) is the only definition of validity, shared by the read path, the write path and serve(). lib/record-picker.js (hostOverride, hostAction, subFolderFor) decides which entry governs a request and which folder it resolves to; it carries the parity invariant with the inline copy in TheseusNavigator/main.js. Tests: test/manifest-hosts.test.mjs.

Canonicalisation and signing

The signable bytes are the JSON serialisation of the manifest without the sig field, with:

  • keys sorted lexicographically at every level
  • no insignificant whitespace (JSON.stringify with no space arg)
  • \u-escape every non-ASCII character
  • null fields OMITTED ("CNAME": null becomes absent)

The signature is sign(sha256(canonical_bytes)) with the wallet key whose address currently holds the name's NFT certificate.

Verification, at the resolver:

  1. Read the name's chain record. Follow records.s3 to the Sia bucket.
  2. Fetch _records.json from that bucket.
  3. Extract sig, recompute sha256(canonical_bytes) over the rest.
  4. Recover the signing pubkey → derive the CashAddress → compare to the current NFT owner address from the chain.
  5. Compare seq to the cached last-seen seq for this name. Reject if not strictly greater.
  6. If everything matches: cache the new seq, apply the DNS records.

Any step failing means "no records" — the resolver falls back to whatever was already on the chain (h, s3 for content, etc.).

Endpoints (gateway)

GET /api/records/<name> — read-through of the signed manifest. Returns 200 with the JSON if present, 404 if missing, 5xx on Sia errors. Cacheable for a minute. Anyone can call this; it's public.

POST /api/records/<name> — write. Body IS the manifest JSON. The gateway:

  1. Verifies the signature matches the current on-chain NFT owner.
  2. Verifies seq is strictly greater than the last-seen seq (from GETing the current manifest if any).
  3. Uploads the JSON to the name's Sia bucket at _records.json.
  4. Returns 200 with {seq, bytes, sia_key}.

Rate-limit: 1 write / 5 s per name. Refuses if the on-chain record has no s3 pointer yet — the name has no storage location, no place to write.

Precedence and interaction with existing records

The signed manifest complements the on-chain records — it doesn't replace them:

  • Content (h, s3, p, u, ip) still comes from chain.
  • DNS records come from the manifest — chain has never carried them.

A future extension (v2) could allow the manifest to override h/s3/etc. for owners who want everything off-chain. Not shipped in v1 because the existing content model already works.

Threat model

  • Operator tampers with the manifest. Signature verification catches it — the resolver rejects and falls back to on-chain records.
  • Replay of an old manifest. seq monotonicity catches it — resolvers keep the highest-seen seq.
  • Owner's key compromised. Same as chain-side compromise: attacker controls the NFT and can sign whatever. Move the name to a new key.
  • Sia object deleted. The name still resolves via chain records; DNS records disappear until the manifest is republished.
  • Gateway hostility. The gateway signs nothing itself — it only relays and enforces seq monotonicity. A hostile gateway can refuse to accept writes but cannot forge them; users can bypass it entirely by writing directly to Sia with their own credentials (v2 flow).

What ships in v1

  • The spec (this doc).
  • Gateway /api/records/<name> (GET + POST) with signature verification and Sia read-through.
  • Portal DNS editor that composes a manifest, asks the wallet to sign, and POSTs to the gateway.
  • Resolver support in Theseus + Ariadne staged behind a flag until the portal-side has been in production for a week.

Shipped in v2

  • hosts: per-subdomain routing (path, u, block) and per-host dns.
  • CAA/SRV records.
  • Portal editor for both halves, on one signed manifest and one publish.

Still open

  • Direct-to-Sia write flow for users who hold their own S3 credentials.
  • Owner-controlled manifest that overrides h/s3/etc. for the apex.
  • HTTPS/SVCB records.
  • The public authoritative DNS bridge. Until it exists the dns half of the manifest is write-only: the daemon's own server (lib/dns-server.js) answers A with the local resolver address and NODATA for everything else, and the only consumer of a manifest A record is Theseus, as a last resort when a name carries no content record.
  • Federated resolver caches for seq (mitigates the case where two writes race and each thinks its seq is winning).