diff options
Diffstat (limited to 'src/bip353.js')
| -rw-r--r-- | src/bip353.js | 61 |
1 files changed, 17 insertions, 44 deletions
diff --git a/src/bip353.js b/src/bip353.js index 2bacb65..efa1721 100644 --- a/src/bip353.js +++ b/src/bip353.js @@ -1,55 +1,28 @@ -// BIP-353 DNS payment instructions — resolving ₿name@domain to a BIP-21 URI. +// BIP-353 DNS payment instructions — resolving name@domain to a BIP-21 URI. // // The record lives at <name>.user._bitcoin-payment.<domain> as a DNSSEC-signed -// TXT containing one `bitcoin:` URI. Browsers can't do raw DNS, so we resolve -// over DNS-over-HTTPS. TRUST LEVEL, stated honestly: full client-side DNSSEC -// validation (what the BIP ultimately wants) is not implemented yet — instead -// we require agreement between TWO independent validating resolvers -// (Cloudflare and Google), each reporting the answer DNSSEC-authenticated -// (AD flag). Collusion or simultaneous compromise of both is the residual -// risk; a native chain validator is the follow-up. +// TXT containing one `bitcoin:` URI. Resolution runs full client-side DNSSEC +// chain validation (src/dnssec.js): the DoH resolver is untrusted transport, +// and every answer we act on is provably signed from the ICANN root down. -const RESOLVERS = [ - 'https://cloudflare-dns.com/dns-query', - 'https://dns.google/resolve', -]; - -// "a" "b" quoted character-strings → concatenated payload -function txtPayload(data) { - const parts = [...String(data).matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => m[1].replace(/\\(.)/g, '$1')); - return parts.length ? parts.join('') : String(data); -} - -async function queryOne(base, label) { - const r = await fetch(`${base}?name=${label}&type=TXT`, { headers: { accept: 'application/dns-json' } }); - if (!r.ok) throw new Error(`resolver ${r.status}`); - const j = await r.json(); - const uris = (j.Answer || []) - .filter((a) => a.type === 16) - .map((a) => txtPayload(a.data)) - .filter((t) => /^bitcoin:/i.test(t)); - // BIP-353: multiple bitcoin: TXT records at one label are invalid - if (uris.length > 1) throw new Error('multiple payment records'); - return { uri: uris[0] || null, ad: !!j.AD }; -} +import { resolveTxtValidated } from './dnssec.js'; // name like "adam", domain like "halwallet.app" → the bitcoin: URI, or null -// when no record exists. Throws when resolution can't be trusted. +// when no record exists. Throws when the answer can't be proven authentic. export async function resolveBip353(name, domain) { const label = `${name}.user._bitcoin-payment.${domain}`; - const results = await Promise.allSettled(RESOLVERS.map((r) => queryOne(r, label))); - const ok = results.filter((r) => r.status === 'fulfilled').map((r) => r.value); - if (ok.length < 2) { - // one resolver down: accept a single authenticated answer, refuse a - // single unauthenticated one - if (ok.length === 1 && ok[0].uri && ok[0].ad) return ok[0].uri; - if (ok.length === 1 && !ok[0].uri) return null; - throw new Error('could not resolve the name'); + let res; + try { + res = await resolveTxtValidated(label); + } catch (e) { + if (/^no TXT record/.test(e.message)) return null; + throw e; } - if (ok[0].uri !== ok[1].uri) throw new Error('resolvers disagree — refusing'); - if (!ok[0].uri) return null; - if (!ok[0].ad || !ok[1].ad) throw new Error('name is not DNSSEC-signed'); - return ok[0].uri; + const uris = res.txts.filter((t) => /^bitcoin:/i.test(t)); + if (!uris.length) return null; + // BIP-353: multiple bitcoin: TXT records at one label are invalid + if (uris.length > 1) throw new Error('multiple payment records — invalid'); + return uris[0]; } // A pasted payment name: optional ₿, user@domain with a real-looking TLD. |
