@x1id/resolve 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/domainProof.d.ts +166 -0
- package/dist/domainProof.js +278 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Independent, re-verifiable domain->wallet proof via DNS-over-HTTPS (#8318,
|
|
3
|
+
* Approach C — the "auditable attestor" honesty upgrade).
|
|
4
|
+
*
|
|
5
|
+
* # Why this exists
|
|
6
|
+
*
|
|
7
|
+
* A `dns`-kind {@link import("./attestation.js").HandleAttestation} records only
|
|
8
|
+
* that x1id's admin-rotatable attestor key *signed off* on an off-chain DNS
|
|
9
|
+
* lookup ("attestation.ts" says this plainly): the chain stores the attestor's
|
|
10
|
+
* say-so, not a proof. The fully-trustless answers (an on-chain DNSSEC verifier,
|
|
11
|
+
* or a zkTLS proof — Approaches A/B in `docs/trustless-domain-proof-plan.md`)
|
|
12
|
+
* are BLOCKED on this X1 fork because the runtime does not have the crypto
|
|
13
|
+
* primitives they need active (`big_mod_exp` / `secp256r1` / `alt_bn128` are all
|
|
14
|
+
* feature-gated off). Until one of those lands, this module is the shippable
|
|
15
|
+
* middle ground: it lets ANYONE independently re-resolve the domain's DNS record
|
|
16
|
+
* and confirm the binding, so the attestor can no longer lie *undetected*. Trust
|
|
17
|
+
* moves from "the x1id attestor" to "DNS + the domain owner" — a real, cheap
|
|
18
|
+
* upgrade that needs no on-chain change.
|
|
19
|
+
*
|
|
20
|
+
* It is dependency-free and runs in both the browser and Node: a DoH query is a
|
|
21
|
+
* plain HTTPS GET, and the JSON shape ("application/dns-json", the Cloudflare /
|
|
22
|
+
* Google convention) is parsed by hand, like the rest of this package.
|
|
23
|
+
*
|
|
24
|
+
* # The TXT record convention (the interoperability contract)
|
|
25
|
+
*
|
|
26
|
+
* A domain owner proves control of `<domain>` by publishing a TXT record at the
|
|
27
|
+
* name **`_x1id.<domain>`** whose value is:
|
|
28
|
+
*
|
|
29
|
+
* x1id-wallet=<base58 wallet pubkey>
|
|
30
|
+
*
|
|
31
|
+
* - The prefix is the exported {@link TXT_BINDING_PREFIX} (`x1id-wallet=`).
|
|
32
|
+
* - `<base58 wallet pubkey>` is the 32-byte X1/SVM wallet address, base58, that
|
|
33
|
+
* the domain is bound to (the handle's authority wallet). Multiple TXT records
|
|
34
|
+
* under the same name are allowed — a domain may bind more than one wallet
|
|
35
|
+
* (e.g. during a key rotation); {@link resolveDomainBinding} returns them all.
|
|
36
|
+
* - A single TXT record split into several character-strings (RFC 1035 §3.3.14 /
|
|
37
|
+
* RFC 7208 §3.3) is concatenated with no separator before the prefix is
|
|
38
|
+
* matched, exactly as a mail SPF/DKIM verifier would.
|
|
39
|
+
*
|
|
40
|
+
* This is the contract a domain owner follows, so it is deliberately simple and
|
|
41
|
+
* stable. It mirrors the `_dnslink`/`_atproto`/`_domainkey` underscore-prefixed
|
|
42
|
+
* convention rather than putting x1id data on the apex TXT (which would collide
|
|
43
|
+
* with SPF/verification tokens from every other service).
|
|
44
|
+
*
|
|
45
|
+
* # NOTE — the paired attestor-side follow-up (NOT done here)
|
|
46
|
+
*
|
|
47
|
+
* This SDK-only change gives verifiers the re-check primitive. The *other* half
|
|
48
|
+
* of Approach C — having the attestor publish the exact `_x1id.<domain>` TXT it
|
|
49
|
+
* checked inside the attestation's `evidence_hash` preimage bundle, so a
|
|
50
|
+
* back-dated attestation whose TXT was later removed is still disprovable — is a
|
|
51
|
+
* change to the admin-api attest tooling (`tools/`), tracked as a FOLLOW-UP. See
|
|
52
|
+
* `docs/trustless-domain-proof-plan.md` §4. Until that ships,
|
|
53
|
+
* {@link verifyDomainBinding} can only confirm a binding that is *still live* in
|
|
54
|
+
* DNS; it cannot audit a removed one.
|
|
55
|
+
*/
|
|
56
|
+
import type { RpcFn } from "./accounts.js";
|
|
57
|
+
/** The canonical prefix of the `_x1id.<domain>` TXT binding value. A TXT string
|
|
58
|
+
* that (after character-string concatenation) starts with this, followed by a
|
|
59
|
+
* valid base58 32-byte address, is an x1id binding record. */
|
|
60
|
+
export declare const TXT_BINDING_PREFIX = "x1id-wallet=";
|
|
61
|
+
/** The DNS label prefixed to a domain to locate its x1id binding TXT records —
|
|
62
|
+
* the query name is `${TXT_NAME_PREFIX}.<domain>`. */
|
|
63
|
+
export declare const TXT_NAME_PREFIX = "_x1id";
|
|
64
|
+
/** Built-in DoH endpoints (both speak the `application/dns-json` shape). */
|
|
65
|
+
declare const DOH_PROVIDERS: Readonly<{
|
|
66
|
+
readonly cloudflare: "https://cloudflare-dns.com/dns-query";
|
|
67
|
+
readonly google: "https://dns.google/resolve";
|
|
68
|
+
}>;
|
|
69
|
+
export interface DomainProofOptions {
|
|
70
|
+
/** Which built-in DoH provider to use when {@link dohUrl} is not given.
|
|
71
|
+
* Default `"cloudflare"`. */
|
|
72
|
+
readonly provider?: keyof typeof DOH_PROVIDERS;
|
|
73
|
+
/** A full DoH endpoint base URL (overrides {@link provider}), e.g. a
|
|
74
|
+
* self-hosted resolver. The query string (`?name=...&type=TXT`) is appended;
|
|
75
|
+
* a URL that already has query params is respected (`&` is used). Must speak
|
|
76
|
+
* the `application/dns-json` response shape. */
|
|
77
|
+
readonly dohUrl?: string;
|
|
78
|
+
/** `fetch` override — inject one in tests (return canned DoH JSON) or to use a
|
|
79
|
+
* custom transport. Defaults to `globalThis.fetch`. */
|
|
80
|
+
readonly fetchImpl?: typeof fetch;
|
|
81
|
+
/** Abort the DoH request after this many ms. Default 6000. */
|
|
82
|
+
readonly timeoutMs?: number;
|
|
83
|
+
}
|
|
84
|
+
/** The outcome of resolving a domain's `_x1id` TXT bindings. `ok` is about the
|
|
85
|
+
* LOOKUP succeeding, not about any wallet being found: a domain that simply has
|
|
86
|
+
* no binding is `ok: true` with an empty `wallets`. `ok: false` means the DNS
|
|
87
|
+
* query itself failed (network, timeout, DoH error) and the answer is unknown —
|
|
88
|
+
* never a throw. */
|
|
89
|
+
export interface DomainBindingResult {
|
|
90
|
+
/** The DNS lookup completed and its answer was parsed. */
|
|
91
|
+
readonly ok: boolean;
|
|
92
|
+
/** The domain queried (lowercased, canonical). */
|
|
93
|
+
readonly domain: string;
|
|
94
|
+
/** Every distinct wallet the `_x1id.<domain>` TXT records bind, base58
|
|
95
|
+
* (canonical spelling), first-seen order, de-duplicated. Empty when the
|
|
96
|
+
* domain publishes no valid binding (or when `ok` is false). */
|
|
97
|
+
readonly wallets: string[];
|
|
98
|
+
/** Why the lookup failed, or a note when it succeeded with nothing usable.
|
|
99
|
+
* Absent on a clean success that found bindings. */
|
|
100
|
+
readonly reason?: string;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Resolve the wallet(s) a domain binds via its `_x1id.<domain>` TXT records,
|
|
104
|
+
* independently of x1id's attestor — a DoH GET, parsed and validated defensively.
|
|
105
|
+
*
|
|
106
|
+
* Never throws: a network failure, timeout, or malformed answer comes back as
|
|
107
|
+
* `{ ok: false, reason }`; a domain with no binding as `{ ok: true, wallets: [] }`.
|
|
108
|
+
*/
|
|
109
|
+
export declare function resolveDomainBinding(domain: string, opts?: DomainProofOptions): Promise<DomainBindingResult>;
|
|
110
|
+
/** The outcome of checking a domain against an expected wallet. */
|
|
111
|
+
export interface VerifyDomainBindingResult {
|
|
112
|
+
/** True iff the domain's `_x1id` TXT binds `expectedWallet` and the lookup
|
|
113
|
+
* succeeded. */
|
|
114
|
+
readonly ok: boolean;
|
|
115
|
+
/** Every wallet the domain binds (base58, canonical) — for context/UX even on
|
|
116
|
+
* a mismatch. Empty on a lookup failure. */
|
|
117
|
+
readonly found: string[];
|
|
118
|
+
/** Why `ok` is false: `"invalid-domain"` / `"invalid-wallet"` for bad inputs,
|
|
119
|
+
* a DoH failure reason (`"timeout"`, `"network-error"`, ...), `"not-found"`
|
|
120
|
+
* when the domain publishes no binding, or `"mismatch"` when it binds other
|
|
121
|
+
* wallets but not the expected one. Absent when `ok` is true. */
|
|
122
|
+
readonly reason?: string;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The core "anyone can check it" primitive: does `<domain>` independently bind
|
|
126
|
+
* `expectedWallet` in DNS? Returns `{ ok, found, reason? }` and never throws.
|
|
127
|
+
*
|
|
128
|
+
* A verifier calling this trusts DNS and the domain owner — NOT x1id's attestor.
|
|
129
|
+
*/
|
|
130
|
+
export declare function verifyDomainBinding(domain: string, expectedWallet: string, opts?: DomainProofOptions): Promise<VerifyDomainBindingResult>;
|
|
131
|
+
/** The combined on-chain-attestation + live-DNS check. */
|
|
132
|
+
export interface VerifyDomainAttestationResult {
|
|
133
|
+
/** Both halves hold: x1id attested the domain (a non-stale `dns` attestation)
|
|
134
|
+
* AND DNS still independently confirms the binding. This is the strongest
|
|
135
|
+
* signal this SDK can give for a domain today. */
|
|
136
|
+
readonly ok: boolean;
|
|
137
|
+
/** The handle holds a NON-STALE `dns`-kind attestation for the current owner
|
|
138
|
+
* ("x1id attested it"). */
|
|
139
|
+
readonly attested: boolean;
|
|
140
|
+
/** DNS independently confirms the binding right now ("still current, attestor
|
|
141
|
+
* couldn't have lied"). */
|
|
142
|
+
readonly dnsConfirmed: boolean;
|
|
143
|
+
/** The wallet(s) the domain currently binds in DNS. */
|
|
144
|
+
readonly found: string[];
|
|
145
|
+
/** A note when `ok` is false — e.g. `"attestation-error"` if the chain read
|
|
146
|
+
* threw, or the {@link verifyDomainBinding} reason for the DNS half. */
|
|
147
|
+
readonly reason?: string;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Convenience: confirm BOTH that x1id attested a domain AND that DNS still
|
|
151
|
+
* independently backs it up — so a caller learns not just "x1id says so" but
|
|
152
|
+
* "and it's verifiably still true", closing the attestor's ability to have lied.
|
|
153
|
+
*
|
|
154
|
+
* `registeredAt` / `recordsClearedAt` are the owning Handle's fields the caller
|
|
155
|
+
* already decoded (see {@link import("./accounts.js").parseHandleAccount}); they
|
|
156
|
+
* are MANDATORY here for the same reason every attestation read demands them —
|
|
157
|
+
* the staleness rule (`attested_at >= registered_at`, and `>= records_cleared_at`)
|
|
158
|
+
* decides whether an attestation vouches for the *current* owner at all. The
|
|
159
|
+
* two halves are evaluated independently, so a caller can see which one holds:
|
|
160
|
+
* a chain-read failure does not suppress the DNS answer, and vice-versa.
|
|
161
|
+
*
|
|
162
|
+
* Never throws: a failed chain read degrades to `attested: false` with
|
|
163
|
+
* `reason: "attestation-error"`, mirroring `signin.ts`'s best-effort summary.
|
|
164
|
+
*/
|
|
165
|
+
export declare function verifyDomainAttestation(rpc: RpcFn, programId: string, handleAccount: string, registeredAt: bigint, recordsClearedAt: bigint, domain: string, expectedWallet: string, opts?: DomainProofOptions): Promise<VerifyDomainAttestationResult>;
|
|
166
|
+
export {};
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Independent, re-verifiable domain->wallet proof via DNS-over-HTTPS (#8318,
|
|
3
|
+
* Approach C — the "auditable attestor" honesty upgrade).
|
|
4
|
+
*
|
|
5
|
+
* # Why this exists
|
|
6
|
+
*
|
|
7
|
+
* A `dns`-kind {@link import("./attestation.js").HandleAttestation} records only
|
|
8
|
+
* that x1id's admin-rotatable attestor key *signed off* on an off-chain DNS
|
|
9
|
+
* lookup ("attestation.ts" says this plainly): the chain stores the attestor's
|
|
10
|
+
* say-so, not a proof. The fully-trustless answers (an on-chain DNSSEC verifier,
|
|
11
|
+
* or a zkTLS proof — Approaches A/B in `docs/trustless-domain-proof-plan.md`)
|
|
12
|
+
* are BLOCKED on this X1 fork because the runtime does not have the crypto
|
|
13
|
+
* primitives they need active (`big_mod_exp` / `secp256r1` / `alt_bn128` are all
|
|
14
|
+
* feature-gated off). Until one of those lands, this module is the shippable
|
|
15
|
+
* middle ground: it lets ANYONE independently re-resolve the domain's DNS record
|
|
16
|
+
* and confirm the binding, so the attestor can no longer lie *undetected*. Trust
|
|
17
|
+
* moves from "the x1id attestor" to "DNS + the domain owner" — a real, cheap
|
|
18
|
+
* upgrade that needs no on-chain change.
|
|
19
|
+
*
|
|
20
|
+
* It is dependency-free and runs in both the browser and Node: a DoH query is a
|
|
21
|
+
* plain HTTPS GET, and the JSON shape ("application/dns-json", the Cloudflare /
|
|
22
|
+
* Google convention) is parsed by hand, like the rest of this package.
|
|
23
|
+
*
|
|
24
|
+
* # The TXT record convention (the interoperability contract)
|
|
25
|
+
*
|
|
26
|
+
* A domain owner proves control of `<domain>` by publishing a TXT record at the
|
|
27
|
+
* name **`_x1id.<domain>`** whose value is:
|
|
28
|
+
*
|
|
29
|
+
* x1id-wallet=<base58 wallet pubkey>
|
|
30
|
+
*
|
|
31
|
+
* - The prefix is the exported {@link TXT_BINDING_PREFIX} (`x1id-wallet=`).
|
|
32
|
+
* - `<base58 wallet pubkey>` is the 32-byte X1/SVM wallet address, base58, that
|
|
33
|
+
* the domain is bound to (the handle's authority wallet). Multiple TXT records
|
|
34
|
+
* under the same name are allowed — a domain may bind more than one wallet
|
|
35
|
+
* (e.g. during a key rotation); {@link resolveDomainBinding} returns them all.
|
|
36
|
+
* - A single TXT record split into several character-strings (RFC 1035 §3.3.14 /
|
|
37
|
+
* RFC 7208 §3.3) is concatenated with no separator before the prefix is
|
|
38
|
+
* matched, exactly as a mail SPF/DKIM verifier would.
|
|
39
|
+
*
|
|
40
|
+
* This is the contract a domain owner follows, so it is deliberately simple and
|
|
41
|
+
* stable. It mirrors the `_dnslink`/`_atproto`/`_domainkey` underscore-prefixed
|
|
42
|
+
* convention rather than putting x1id data on the apex TXT (which would collide
|
|
43
|
+
* with SPF/verification tokens from every other service).
|
|
44
|
+
*
|
|
45
|
+
* # NOTE — the paired attestor-side follow-up (NOT done here)
|
|
46
|
+
*
|
|
47
|
+
* This SDK-only change gives verifiers the re-check primitive. The *other* half
|
|
48
|
+
* of Approach C — having the attestor publish the exact `_x1id.<domain>` TXT it
|
|
49
|
+
* checked inside the attestation's `evidence_hash` preimage bundle, so a
|
|
50
|
+
* back-dated attestation whose TXT was later removed is still disprovable — is a
|
|
51
|
+
* change to the admin-api attest tooling (`tools/`), tracked as a FOLLOW-UP. See
|
|
52
|
+
* `docs/trustless-domain-proof-plan.md` §4. Until that ships,
|
|
53
|
+
* {@link verifyDomainBinding} can only confirm a binding that is *still live* in
|
|
54
|
+
* DNS; it cannot audit a removed one.
|
|
55
|
+
*/
|
|
56
|
+
import { decodeBase58_32, encodeBase58 } from "./base58.js";
|
|
57
|
+
import { fetchAttestations } from "./attestation.js";
|
|
58
|
+
import { attestationsPassGate, GateKind } from "./gate.js";
|
|
59
|
+
/** The canonical prefix of the `_x1id.<domain>` TXT binding value. A TXT string
|
|
60
|
+
* that (after character-string concatenation) starts with this, followed by a
|
|
61
|
+
* valid base58 32-byte address, is an x1id binding record. */
|
|
62
|
+
export const TXT_BINDING_PREFIX = "x1id-wallet=";
|
|
63
|
+
/** The DNS label prefixed to a domain to locate its x1id binding TXT records —
|
|
64
|
+
* the query name is `${TXT_NAME_PREFIX}.<domain>`. */
|
|
65
|
+
export const TXT_NAME_PREFIX = "_x1id";
|
|
66
|
+
/** DoH JSON `type` code for a TXT record (RFC 1035). */
|
|
67
|
+
const DNS_TYPE_TXT = 16;
|
|
68
|
+
/** Built-in DoH endpoints (both speak the `application/dns-json` shape). */
|
|
69
|
+
const DOH_PROVIDERS = Object.freeze({
|
|
70
|
+
cloudflare: "https://cloudflare-dns.com/dns-query",
|
|
71
|
+
google: "https://dns.google/resolve",
|
|
72
|
+
});
|
|
73
|
+
const DEFAULT_TIMEOUT_MS = 6000;
|
|
74
|
+
/** A domain is a case-insensitive dotted hostname (1..253 chars, labels 1..63 of
|
|
75
|
+
* `[a-z0-9-]`, no leading/trailing hyphen). Strict enough that the name can be
|
|
76
|
+
* safely interpolated into a query and can never smuggle a second query param. */
|
|
77
|
+
const DOMAIN_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/;
|
|
78
|
+
function normalizeDomain(domain) {
|
|
79
|
+
if (typeof domain !== "string")
|
|
80
|
+
return null;
|
|
81
|
+
// Tolerate a trailing dot (fully-qualified form) and case; reject everything
|
|
82
|
+
// else so the name is safe to interpolate into the query.
|
|
83
|
+
const d = domain.trim().replace(/\.$/, "").toLowerCase();
|
|
84
|
+
if (!DOMAIN_RE.test(d))
|
|
85
|
+
return null;
|
|
86
|
+
return d;
|
|
87
|
+
}
|
|
88
|
+
function buildDohUrl(base, name) {
|
|
89
|
+
const q = `name=${encodeURIComponent(name)}&type=TXT`;
|
|
90
|
+
return base.includes("?") ? `${base}&${q}` : `${base}?${q}`;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Concatenate a DoH TXT `data` field into its logical string.
|
|
94
|
+
*
|
|
95
|
+
* DoH returns a TXT record's RDATA as its character-strings, each wrapped in
|
|
96
|
+
* double quotes; a record split into several character-strings comes back as
|
|
97
|
+
* several quoted runs (e.g. `"\"x1id-ver\" \"ification=...\""`). Per RFC 1035
|
|
98
|
+
* §3.3.14 they concatenate with NO separator. Escaped `\"` and `\\` inside a
|
|
99
|
+
* run are unescaped. A `data` with no quotes at all (some resolvers) is used
|
|
100
|
+
* verbatim.
|
|
101
|
+
*/
|
|
102
|
+
function unquoteTxtData(data) {
|
|
103
|
+
if (!data.includes('"'))
|
|
104
|
+
return data;
|
|
105
|
+
let out = "";
|
|
106
|
+
let inStr = false;
|
|
107
|
+
for (let i = 0; i < data.length; i++) {
|
|
108
|
+
const c = data[i];
|
|
109
|
+
if (!inStr) {
|
|
110
|
+
if (c === '"')
|
|
111
|
+
inStr = true;
|
|
112
|
+
// characters between quoted runs (whitespace) are dropped
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (c === "\\") {
|
|
116
|
+
// escaped char: take the next byte literally
|
|
117
|
+
const next = data[i + 1];
|
|
118
|
+
if (next !== undefined) {
|
|
119
|
+
out += next;
|
|
120
|
+
i++;
|
|
121
|
+
}
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (c === '"') {
|
|
125
|
+
inStr = false;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
out += c;
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
/** Extract a canonical base58 wallet from one concatenated TXT string, or null
|
|
133
|
+
* if it is not a well-formed x1id binding. */
|
|
134
|
+
function walletFromTxt(txt) {
|
|
135
|
+
if (!txt.startsWith(TXT_BINDING_PREFIX))
|
|
136
|
+
return null;
|
|
137
|
+
const raw = txt.slice(TXT_BINDING_PREFIX.length).trim();
|
|
138
|
+
const bytes = decodeBase58_32(raw);
|
|
139
|
+
if (!bytes)
|
|
140
|
+
return null;
|
|
141
|
+
// Re-encode so a non-canonical base58 spelling and the caller's wallet compare
|
|
142
|
+
// byte-for-byte, exactly like the rest of the SDK does.
|
|
143
|
+
return encodeBase58(bytes);
|
|
144
|
+
}
|
|
145
|
+
async function fetchDohJson(url, fetchImpl, timeoutMs) {
|
|
146
|
+
const controller = new AbortController();
|
|
147
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
148
|
+
try {
|
|
149
|
+
const res = await fetchImpl(url, {
|
|
150
|
+
signal: controller.signal,
|
|
151
|
+
// Cloudflare's /dns-query REQUIRES this to return JSON; harmless for Google.
|
|
152
|
+
headers: { accept: "application/dns-json" },
|
|
153
|
+
});
|
|
154
|
+
if (!res.ok)
|
|
155
|
+
return { ok: false, reason: `doh-http-${res.status}` };
|
|
156
|
+
const json = (await res.json());
|
|
157
|
+
return { ok: true, json };
|
|
158
|
+
}
|
|
159
|
+
catch (e) {
|
|
160
|
+
const reason = e?.name === "AbortError" ? "timeout" : "network-error";
|
|
161
|
+
return { ok: false, reason };
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Resolve the wallet(s) a domain binds via its `_x1id.<domain>` TXT records,
|
|
169
|
+
* independently of x1id's attestor — a DoH GET, parsed and validated defensively.
|
|
170
|
+
*
|
|
171
|
+
* Never throws: a network failure, timeout, or malformed answer comes back as
|
|
172
|
+
* `{ ok: false, reason }`; a domain with no binding as `{ ok: true, wallets: [] }`.
|
|
173
|
+
*/
|
|
174
|
+
export async function resolveDomainBinding(domain, opts) {
|
|
175
|
+
const canonical = normalizeDomain(domain);
|
|
176
|
+
if (canonical === null) {
|
|
177
|
+
return { ok: false, domain: String(domain), wallets: [], reason: "invalid-domain" };
|
|
178
|
+
}
|
|
179
|
+
const fetchImpl = opts?.fetchImpl ?? globalThis.fetch;
|
|
180
|
+
if (typeof fetchImpl !== "function") {
|
|
181
|
+
return { ok: false, domain: canonical, wallets: [], reason: "no-fetch" };
|
|
182
|
+
}
|
|
183
|
+
const base = opts?.dohUrl ?? DOH_PROVIDERS[opts?.provider ?? "cloudflare"];
|
|
184
|
+
const url = buildDohUrl(base, `${TXT_NAME_PREFIX}.${canonical}`);
|
|
185
|
+
const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
186
|
+
const res = await fetchDohJson(url, fetchImpl, timeoutMs);
|
|
187
|
+
if (!res.ok)
|
|
188
|
+
return { ok: false, domain: canonical, wallets: [], reason: res.reason };
|
|
189
|
+
const body = res.json;
|
|
190
|
+
if (typeof body !== "object" || body === null) {
|
|
191
|
+
return { ok: false, domain: canonical, wallets: [], reason: "malformed-response" };
|
|
192
|
+
}
|
|
193
|
+
const b = body;
|
|
194
|
+
const status = typeof b.Status === "number" ? b.Status : -1;
|
|
195
|
+
// 0 = NOERROR (parse answers), 3 = NXDOMAIN (the name simply doesn't exist =
|
|
196
|
+
// no binding, a clean empty result). Anything else (SERVFAIL, etc.) is an
|
|
197
|
+
// unknown answer, not a proven absence.
|
|
198
|
+
if (status === 3)
|
|
199
|
+
return { ok: true, domain: canonical, wallets: [] };
|
|
200
|
+
if (status !== 0)
|
|
201
|
+
return { ok: false, domain: canonical, wallets: [], reason: `doh-status-${status}` };
|
|
202
|
+
const answers = Array.isArray(b.Answer) ? b.Answer : [];
|
|
203
|
+
const wallets = [];
|
|
204
|
+
for (const ans of answers) {
|
|
205
|
+
if (typeof ans !== "object" || ans === null)
|
|
206
|
+
continue;
|
|
207
|
+
const a = ans;
|
|
208
|
+
if (a.type !== DNS_TYPE_TXT)
|
|
209
|
+
continue; // skip CNAME/other chained answers
|
|
210
|
+
if (typeof a.data !== "string")
|
|
211
|
+
continue;
|
|
212
|
+
const wallet = walletFromTxt(unquoteTxtData(a.data));
|
|
213
|
+
if (wallet && !wallets.includes(wallet))
|
|
214
|
+
wallets.push(wallet);
|
|
215
|
+
}
|
|
216
|
+
return { ok: true, domain: canonical, wallets };
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* The core "anyone can check it" primitive: does `<domain>` independently bind
|
|
220
|
+
* `expectedWallet` in DNS? Returns `{ ok, found, reason? }` and never throws.
|
|
221
|
+
*
|
|
222
|
+
* A verifier calling this trusts DNS and the domain owner — NOT x1id's attestor.
|
|
223
|
+
*/
|
|
224
|
+
export async function verifyDomainBinding(domain, expectedWallet, opts) {
|
|
225
|
+
const wanted = typeof expectedWallet === "string" ? decodeBase58_32(expectedWallet) : null;
|
|
226
|
+
if (!wanted)
|
|
227
|
+
return { ok: false, found: [], reason: "invalid-wallet" };
|
|
228
|
+
const wantedBase58 = encodeBase58(wanted);
|
|
229
|
+
const res = await resolveDomainBinding(domain, opts);
|
|
230
|
+
if (!res.ok)
|
|
231
|
+
return { ok: false, found: [], reason: res.reason ?? "lookup-failed" };
|
|
232
|
+
if (res.wallets.length === 0)
|
|
233
|
+
return { ok: false, found: [], reason: "not-found" };
|
|
234
|
+
if (!res.wallets.includes(wantedBase58)) {
|
|
235
|
+
return { ok: false, found: res.wallets, reason: "mismatch" };
|
|
236
|
+
}
|
|
237
|
+
return { ok: true, found: res.wallets };
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Convenience: confirm BOTH that x1id attested a domain AND that DNS still
|
|
241
|
+
* independently backs it up — so a caller learns not just "x1id says so" but
|
|
242
|
+
* "and it's verifiably still true", closing the attestor's ability to have lied.
|
|
243
|
+
*
|
|
244
|
+
* `registeredAt` / `recordsClearedAt` are the owning Handle's fields the caller
|
|
245
|
+
* already decoded (see {@link import("./accounts.js").parseHandleAccount}); they
|
|
246
|
+
* are MANDATORY here for the same reason every attestation read demands them —
|
|
247
|
+
* the staleness rule (`attested_at >= registered_at`, and `>= records_cleared_at`)
|
|
248
|
+
* decides whether an attestation vouches for the *current* owner at all. The
|
|
249
|
+
* two halves are evaluated independently, so a caller can see which one holds:
|
|
250
|
+
* a chain-read failure does not suppress the DNS answer, and vice-versa.
|
|
251
|
+
*
|
|
252
|
+
* Never throws: a failed chain read degrades to `attested: false` with
|
|
253
|
+
* `reason: "attestation-error"`, mirroring `signin.ts`'s best-effort summary.
|
|
254
|
+
*/
|
|
255
|
+
export async function verifyDomainAttestation(rpc, programId, handleAccount, registeredAt, recordsClearedAt, domain, expectedWallet, opts) {
|
|
256
|
+
// Half 1 (on-chain): a non-stale dns attestation for the current owner. A
|
|
257
|
+
// read failure must not sink the DNS half — degrade, never throw.
|
|
258
|
+
let attested = false;
|
|
259
|
+
let attestationReason;
|
|
260
|
+
try {
|
|
261
|
+
const attestations = await fetchAttestations(rpc, programId, handleAccount, registeredAt, recordsClearedAt);
|
|
262
|
+
attested = attestationsPassGate(attestations, { kind: GateKind.dns });
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
attestationReason = "attestation-error";
|
|
266
|
+
}
|
|
267
|
+
// Half 2 (off-chain, independent): DNS still binds the expected wallet.
|
|
268
|
+
const dns = await verifyDomainBinding(domain, expectedWallet, opts);
|
|
269
|
+
const ok = attested && dns.ok;
|
|
270
|
+
const reason = ok ? undefined : (attestationReason ?? (attested ? dns.reason : "not-attested"));
|
|
271
|
+
return {
|
|
272
|
+
ok,
|
|
273
|
+
attested,
|
|
274
|
+
dnsConfirmed: dns.ok,
|
|
275
|
+
found: dns.found,
|
|
276
|
+
...(reason !== undefined ? { reason } : {}),
|
|
277
|
+
};
|
|
278
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -48,6 +48,7 @@ export * from "./clearRecords.js";
|
|
|
48
48
|
export * from "./commitReveal.js";
|
|
49
49
|
export * from "./pnftTransfer.js";
|
|
50
50
|
export * from "./signin.js";
|
|
51
|
+
export * from "./domainProof.js";
|
|
51
52
|
import { type Chain, type Resolved } from "./types.js";
|
|
52
53
|
import { WasmResolver } from "./wasm.js";
|
|
53
54
|
import { type HandleRecord } from "./records.js";
|
package/dist/index.js
CHANGED
|
@@ -55,6 +55,7 @@ export * from "./clearRecords.js";
|
|
|
55
55
|
export * from "./commitReveal.js";
|
|
56
56
|
export * from "./pnftTransfer.js";
|
|
57
57
|
export * from "./signin.js";
|
|
58
|
+
export * from "./domainProof.js";
|
|
58
59
|
import { ResolveError, CHAIN_COIN_TYPE } from "./types.js";
|
|
59
60
|
import { parseName } from "./parse.js";
|
|
60
61
|
import { encodeBase58, decodeBase58_32 } from "./base58.js";
|