155 lines
6.4 KiB
Markdown
155 lines
6.4 KiB
Markdown
|
|
# Signed records manifest (BNS records v1)
|
||
|
|
|
||
|
|
**Status:** shipped v1 — 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:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"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. Additional slots (`CAA`, `SRV`) reserved for v2.
|
||
|
|
- `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).
|
||
|
|
|
||
|
|
### 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
|
||
|
|
`GET`ing 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.
|
||
|
|
|
||
|
|
### What ships in v2
|
||
|
|
|
||
|
|
- Direct-to-Sia write flow for users who hold their own S3 credentials.
|
||
|
|
- Owner-controlled manifest that overrides `h`/`s3`/etc. entirely.
|
||
|
|
- CAA/SRV/HTTPS records.
|
||
|
|
- Federated resolver caches for `seq` (mitigates the case where two writes
|
||
|
|
race and each thinks its `seq` is winning).
|