@sdxc/doh 2026.10.6 → 2026.10.9
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/README.md +141 -37
- package/dist/caa/evaluate.d.ts +20 -0
- package/dist/caa/evaluate.js +87 -0
- package/dist/caa/may-issue.d.ts +21 -0
- package/dist/caa/may-issue.js +21 -0
- package/dist/caa/property.d.ts +19 -0
- package/dist/caa/property.js +54 -0
- package/dist/caa/relevant-set.d.ts +35 -0
- package/dist/caa/relevant-set.js +61 -0
- package/dist/caa/types.d.ts +137 -0
- package/dist/caa/types.js +0 -0
- package/dist/caa.d.ts +13 -0
- package/dist/caa.js +12 -0
- package/dist/errors.d.ts +4 -5
- package/dist/errors.js +5 -5
- package/dist/index.d.ts +1 -2
- package/dist/index.js +1 -2
- package/dist/resolve.js +10 -7
- package/dist/types.d.ts +22 -69
- package/dist/types.js +0 -8
- package/package.json +5 -3
- package/dist/character-strings.d.ts +0 -22
- package/dist/character-strings.js +0 -72
- package/dist/parse-record-data.d.ts +0 -23
- package/dist/parse-record-data.js +0 -283
- package/dist/record-types.d.ts +0 -14
- package/dist/record-types.js +0 -54
package/README.md
CHANGED
|
@@ -8,8 +8,9 @@ Typed DNS over HTTPS lookups.
|
|
|
8
8
|
npm add @sdxc/doh
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
Every lookup returns a [`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result) value,
|
|
12
|
-
|
|
11
|
+
Every lookup returns a [`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result) value, and
|
|
12
|
+
records are read by [`@sdxc/zone-file`](https://www.npmjs.com/package/@sdxc/zone-file)'s record data
|
|
13
|
+
codec; both install alongside this package.
|
|
13
14
|
|
|
14
15
|
A runtime with no DNS socket, such as a Cloudflare Worker, resolves names through DNS over HTTPS:
|
|
15
16
|
a `fetch` to a public resolver. This package speaks the DoH JSON API (`application/dns-json`)
|
|
@@ -48,15 +49,6 @@ let verified = await verifyTxtRecord("_verify.example.com", "token_abc123"); //
|
|
|
48
49
|
let pointed = await checkCname("shop.example.com", "custom.hosting.example"); // Result<boolean>
|
|
49
50
|
```
|
|
50
51
|
|
|
51
|
-
### Read Record Data From Anywhere
|
|
52
|
-
|
|
53
|
-
```typescript
|
|
54
|
-
import { parseRecordData } from "@sdxc/doh";
|
|
55
|
-
|
|
56
|
-
let data = parseRecordData("MX", "10 mail.example.com.");
|
|
57
|
-
// success({ type: "MX", preference: 10, exchange: "mail.example.com" })
|
|
58
|
-
```
|
|
59
|
-
|
|
60
52
|
## API
|
|
61
53
|
|
|
62
54
|
### `@sdxc/doh`
|
|
@@ -66,7 +58,9 @@ let data = parseRecordData("MX", "10 mail.example.com.");
|
|
|
66
58
|
Looks up `name`'s records of `type`. The name is sent as written, so an internationalized name must
|
|
67
59
|
already be in its ASCII (punycode) form. The answer holds:
|
|
68
60
|
|
|
69
|
-
- `records` - the records of the asked type, typed by `DoH.RecordFor<Type
|
|
61
|
+
- `records` - the records of the asked type, typed by `DoH.RecordFor<Type>`: the
|
|
62
|
+
`ZoneFile.RecordData<Type>` fields plus `name` and `ttl`, so a resolved record and one read from a
|
|
63
|
+
zone file share every type-specific field
|
|
70
64
|
- `unparsed` - records of the asked type whose data did not parse, as `{ name, ttl, type, data }`,
|
|
71
65
|
so one odd record never fails the whole answer
|
|
72
66
|
- `chain` - the CNAME records followed before reaching `records`
|
|
@@ -86,21 +80,12 @@ back lowercased without the trailing dot; an AAAA address comes back in RFC 5952
|
|
|
86
80
|
- `signal`: aborts the request
|
|
87
81
|
- `timeoutMs`: abandons the request as a `TransportError`, default `5000`
|
|
88
82
|
|
|
89
|
-
Typed record types: `A` (`address`), `AAAA` (`address`), `CNAME
|
|
90
|
-
(`preference`, `exchange`), `TXT` (`text`, `strings`), `CAA` (`
|
|
91
|
-
(`priority`, `weight`, `port`, `target`), `SOA` (`primary`, `mailbox`,
|
|
92
|
-
`retry`, `expire`, `minimum`). Any other type returns records with a raw `data`
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
The presentation-format reader `resolve` uses, for RDATA from anywhere else, such as a zone file.
|
|
97
|
-
Reads RFC 3597 generic data (`\# 4 0A000001`) for the typed types too, which is how Cloudflare
|
|
98
|
-
answers CAA. TXT bare words are separate character-strings, per RFC 1035.
|
|
99
|
-
|
|
100
|
-
```typescript
|
|
101
|
-
parseRecordData("TXT", '"v=DKIM1; p=AAA" "BBB"');
|
|
102
|
-
// success({ type: "TXT", text: "v=DKIM1; p=AAABBB", strings: ["v=DKIM1; p=AAA", "BBB"] })
|
|
103
|
-
```
|
|
83
|
+
Typed record types: `A` (`address`), `AAAA` (`address`), `CNAME`, `PTR` and `DNAME` (`target`), `NS`
|
|
84
|
+
(`host`), `MX` (`preference`, `exchange`), `TXT` (`text`, `strings`), `CAA` (`flags`, `critical`,
|
|
85
|
+
`tag`, `value`), `SRV` (`priority`, `weight`, `port`, `target`), `SOA` (`primary`, `mailbox`,
|
|
86
|
+
`serial`, `refresh`, `retry`, `expire`, `minimum`). Any other type returns records with a raw `data`
|
|
87
|
+
string. To read or print RDATA outside a lookup, use `parseRecordData` and `formatRecordData` from
|
|
88
|
+
`@sdxc/zone-file`.
|
|
104
89
|
|
|
105
90
|
#### `verifyTxtRecord(name, expected, options?): Promise<Result<boolean, DoHError>>`
|
|
106
91
|
|
|
@@ -121,20 +106,101 @@ a loop), so it holds even when the target resolves to nothing. NXDOMAIN is `succ
|
|
|
121
106
|
|
|
122
107
|
#### Errors
|
|
123
108
|
|
|
124
|
-
| Class | When | Extra field
|
|
125
|
-
| -------------------- | -------------------------------------------------------------------------- |
|
|
126
|
-
| `DoHError` | Base of every `resolve` failure |
|
|
127
|
-
| `NameNotFoundError` | RCODE 3, NXDOMAIN | `ttl`: the SOA's negative-caching TTL, or `null`
|
|
128
|
-
| `ServerFailureError` | RCODE 2, SERVFAIL, DNSSEC validation failures included |
|
|
129
|
-
| `ResponseCodeError` | Any other non-zero RCODE (FORMERR, NOTIMP, REFUSED) | `rcode`
|
|
130
|
-
| `TransportError` | Network error, timeout, abort, non-2xx, or a body that is not the envelope | `status`: the HTTP status, `null` when none arrived
|
|
131
|
-
| `RecordDataError` | `parseRecordData` on data that does not fit the type | |
|
|
109
|
+
| Class | When | Extra field |
|
|
110
|
+
| -------------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
|
|
111
|
+
| `DoHError` | Base of every `resolve` failure | |
|
|
112
|
+
| `NameNotFoundError` | RCODE 3, NXDOMAIN | `ttl`: the SOA's negative-caching TTL, or `null`; `authenticated`: the AD flag, a DNSSEC-validated denial |
|
|
113
|
+
| `ServerFailureError` | RCODE 2, SERVFAIL, DNSSEC validation failures included | |
|
|
114
|
+
| `ResponseCodeError` | Any other non-zero RCODE (FORMERR, NOTIMP, REFUSED) | `rcode` |
|
|
115
|
+
| `TransportError` | Network error, timeout, abort, non-2xx, or a body that is not the envelope | `status`: the HTTP status, `null` when none arrived |
|
|
132
116
|
|
|
133
117
|
#### `DoH` namespace
|
|
134
118
|
|
|
135
119
|
Types only: `RecordType`, `Resolver`, `ResolveOptions`, `Answer<Type>`, `RecordFor<Type>`,
|
|
136
|
-
`
|
|
137
|
-
`UnknownRecord`.
|
|
120
|
+
`RecordBase`, one alias per typed record (`ARecord` through `SOARecord`, `PTRRecord` and
|
|
121
|
+
`DNAMERecord` included), and `UnknownRecord`.
|
|
122
|
+
|
|
123
|
+
### `@sdxc/doh/caa`
|
|
124
|
+
|
|
125
|
+
The WebPKI reading of CAA records (RFC 8659, RFC 8657): what a record asks of a certificate
|
|
126
|
+
authority, which RRset applies to a name, and whether a CA may issue for it.
|
|
127
|
+
|
|
128
|
+
#### `parseCaaProperty(record): CAA.Property`
|
|
129
|
+
|
|
130
|
+
Reads one CAA record's meaning:
|
|
131
|
+
|
|
132
|
+
- `issue` / `issuewild` - `{ kind, critical, issuer, malformed, parameters }`. The value follows RFC
|
|
133
|
+
8659's grammar, whitespace around `;` and `=` included. `issuer` is lowercased without a trailing
|
|
134
|
+
dot, and `null` when the value names none (`";"`), which forbids issuance. A value that fails the
|
|
135
|
+
grammar reads as `issuer: null, malformed: true`, forbidding issuance the same way. Parameters keep
|
|
136
|
+
their published order and spelling.
|
|
137
|
+
- `iodef` - `{ kind, critical, url }`, `url` being `null` unless the value is a `mailto:`, `http:` or
|
|
138
|
+
`https:` URL.
|
|
139
|
+
- Any other tag - `{ kind: "unknown", critical, tag, value }`; critical, it forbids issuance.
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
import { parseCaaProperty } from "@sdxc/doh/caa";
|
|
143
|
+
|
|
144
|
+
parseCaaProperty({
|
|
145
|
+
type: "CAA",
|
|
146
|
+
flags: 0,
|
|
147
|
+
critical: false,
|
|
148
|
+
tag: "issue",
|
|
149
|
+
value: "digicert.com; cansignhttpexchanges=yes",
|
|
150
|
+
});
|
|
151
|
+
// { kind: "issue", critical: false, issuer: "digicert.com", malformed: false,
|
|
152
|
+
// parameters: [{ key: "cansignhttpexchanges", value: "yes" }] }
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
#### `findRelevantCaa(domain, options?): Promise<Result<CAA.RelevantSet, DoHError>>`
|
|
156
|
+
|
|
157
|
+
RFC 8659's climb: one `CAA` query for the domain, then for each ancestor up to the TLD, stopping at
|
|
158
|
+
the first answer with records. A leading `*.` and a trailing dot are dropped and the name is
|
|
159
|
+
lowercased. NODATA and NXDOMAIN move the climb on; any other failure ends it as a failure. Records
|
|
160
|
+
reached through a CNAME or DNAME are the target's, with the aliases in `chain`, and when the target
|
|
161
|
+
publishes none the climb continues at the alias's parent.
|
|
162
|
+
|
|
163
|
+
The set holds `name` (where the records were found, `null` when nowhere), `records`, `unparsed`,
|
|
164
|
+
`chain`, `queried` (every name asked, nearest first) and `authenticated`: whether every answer in
|
|
165
|
+
the climb, denials of existence included, carried the AD flag.
|
|
166
|
+
|
|
167
|
+
#### `evaluateCaa(records, request): CAA.Decision`
|
|
168
|
+
|
|
169
|
+
Decides a request against one RRset with no I/O, so records from a zone file decide as records from
|
|
170
|
+
DNS do. `request` is `{ domain, issuer, accountUri?, validationMethod? }`: `domain` starting with
|
|
171
|
+
`*.` asks about a wildcard certificate, and `issuer` is the CA's CAA identifier or a list of them.
|
|
172
|
+
In order:
|
|
173
|
+
|
|
174
|
+
| Reason | `allowed` | When |
|
|
175
|
+
| ------------------------------------------------- | --------- | -------------------------------------------------------------------------------------------------- |
|
|
176
|
+
| `no-policy` | `true` | No records |
|
|
177
|
+
| `critical-tag` | `false` | A critical property of a tag this package does not interpret |
|
|
178
|
+
| `unrestricted` | `true` | No property applies: only `iodef`, non-critical unknown tags, or `issuewild` on a plain name |
|
|
179
|
+
| `authorized` | `true` | An applicable property names one of the issuers and its parameters are satisfied |
|
|
180
|
+
| `account-mismatch` / `validation-method-mismatch` | `false` | Properties name the issuer, and RFC 8657's `accounturi` or `validationmethods` exclude the request |
|
|
181
|
+
| `not-authorized` | `false` | Applicable properties name other issuers, listed in `issuers` |
|
|
182
|
+
| `forbidden` | `false` | Every applicable property names no issuer |
|
|
183
|
+
|
|
184
|
+
For a wildcard domain, any `issuewild` property replaces every `issue`. Authorizations add up, so
|
|
185
|
+
`issue ";"` beside `issue "ca.example"` still authorizes `ca.example`. Issuers match exactly and
|
|
186
|
+
case-insensitively. Two `accounturi` parameters, or a repeated, empty or malformed
|
|
187
|
+
`validationmethods`, are never satisfied; an omitted `accountUri` or `validationMethod` passes that
|
|
188
|
+
check, and the `authorized` decision carries the property whose parameters show what went unchecked.
|
|
189
|
+
|
|
190
|
+
#### `mayIssue(request, options?): Promise<CAA.Verdict>`
|
|
191
|
+
|
|
192
|
+
`findRelevantCaa`, then `evaluateCaa`, with the RRset in `relevant`. Two more refusals:
|
|
193
|
+
`unreadable` when the set holds a record that does not parse, and `lookup-failed` when a query in
|
|
194
|
+
the climb fails, with the failing `name`, `queried` and the `DoHError`. `options` reach every
|
|
195
|
+
query, so `resolver: GOOGLE` confirms a verdict through a second resolver.
|
|
196
|
+
|
|
197
|
+
The verdict predicts what a CA's own lookup finds when the zone serves it what the resolver saw.
|
|
198
|
+
|
|
199
|
+
#### `CAA` namespace
|
|
200
|
+
|
|
201
|
+
Types only: `Record`, `Parameter`, `Property` (`IssueProperty`, `IodefProperty`, `UnknownProperty`),
|
|
202
|
+
`RelevantSet`, `Request`, `Decision`, `Verdict` and one interface per reason. Every one but
|
|
203
|
+
`LookupFailed` is plain data, ready for a log line or storage.
|
|
138
204
|
|
|
139
205
|
## Pattern: Tell A Vanished Name From A Failing Resolver
|
|
140
206
|
|
|
@@ -154,6 +220,44 @@ if (isFailure(answer)) {
|
|
|
154
220
|
let viaAlias = answer.data.chain.length > 0;
|
|
155
221
|
```
|
|
156
222
|
|
|
223
|
+
## Pattern: Compare A Zone File With DNS
|
|
224
|
+
|
|
225
|
+
A zone file and a resolver can spell one record differently: `0 ISSUE "ca.example"` in one,
|
|
226
|
+
RFC 3597 generic data (`\# 15 00 05 69 73 73 75 65 …`) in the other. Parse both and print them, and
|
|
227
|
+
equal records give equal strings.
|
|
228
|
+
|
|
229
|
+
```typescript
|
|
230
|
+
import { resolve } from "@sdxc/doh";
|
|
231
|
+
import { isSuccess } from "@sdxc/result";
|
|
232
|
+
import { formatRecordData, parseRecordData } from "@sdxc/zone-file";
|
|
233
|
+
|
|
234
|
+
let published = parseRecordData("CAA", zoneLine.data);
|
|
235
|
+
let answer = await resolve("example.com", "CAA");
|
|
236
|
+
if (isSuccess(published) && isSuccess(answer)) {
|
|
237
|
+
let live = new Set(answer.data.records.map(formatRecordData));
|
|
238
|
+
let missing = !live.has(formatRecordData(published.data));
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
## Pattern: A Lookup Failure Is A Refusal
|
|
243
|
+
|
|
244
|
+
A CA refuses to issue when its CAA lookup fails, so `mayIssue` answers `allowed: false` for a
|
|
245
|
+
failing lookup too. Checking `allowed` alone treats a SERVFAIL the way a CA does; narrow on
|
|
246
|
+
`reason` to retry first.
|
|
247
|
+
|
|
248
|
+
```typescript
|
|
249
|
+
import { GOOGLE } from "@sdxc/doh";
|
|
250
|
+
import { mayIssue } from "@sdxc/doh/caa";
|
|
251
|
+
|
|
252
|
+
let request = { domain: "*.example.com", issuer: "letsencrypt.org" };
|
|
253
|
+
let verdict = await mayIssue(request);
|
|
254
|
+
if (verdict.reason === "lookup-failed") verdict = await mayIssue(request, { resolver: GOOGLE });
|
|
255
|
+
|
|
256
|
+
if (!verdict.allowed && verdict.reason !== "lookup-failed") {
|
|
257
|
+
verdict.relevant.name; // "example.com": where the blocking policy lives
|
|
258
|
+
}
|
|
259
|
+
```
|
|
260
|
+
|
|
157
261
|
## Pattern: Cache Through The Answer's TTL
|
|
158
262
|
|
|
159
263
|
```typescript
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decides a certificate request against one CAA RRset under RFC 8659 and RFC 8657, with no
|
|
3
|
+
* I/O, so records read from a zone file decide exactly as records from a resolver do: the
|
|
4
|
+
* `issuewild` override, additive authorizations, critical tags and the account and method bindings.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { CAA } from "./types.js";
|
|
10
|
+
/**
|
|
11
|
+
* Decides a request against one RRset. A critical unknown tag refuses before anything
|
|
12
|
+
* else; for a wildcard domain any `issuewild` replaces every `issue`; issuers match
|
|
13
|
+
* exactly, case-insensitively and without the trailing dot. Reserved flag bits are ignored.
|
|
14
|
+
*
|
|
15
|
+
* @param records - The relevant RRset, such as `findRelevantCaa`'s records or a zone file's.
|
|
16
|
+
* @param request - The domain, the CA's identifiers and, optionally, the RFC 8657 account and method.
|
|
17
|
+
* @returns Whether the RRset allows the request, and which property or issuers decided it.
|
|
18
|
+
* @example evaluateCaa([{ type: "CAA", flags: 0, critical: false, tag: "issue", value: "pki.goog" }], { domain: "example.com", issuer: "letsencrypt.org" }) // { allowed: false, reason: "not-authorized", issuers: ["pki.goog"] }
|
|
19
|
+
*/
|
|
20
|
+
export declare function evaluateCaa(records: readonly CAA.Record[], request: CAA.Request): CAA.Decision;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { parseCaaProperty } from "./property.js";
|
|
2
|
+
/** RFC 8657 §4's `validationmethods` value: comma-separated method labels. */
|
|
3
|
+
const METHOD_LIST = /^[A-Za-z0-9](?:-*[A-Za-z0-9])*(?:,[A-Za-z0-9](?:-*[A-Za-z0-9])*)*$/;
|
|
4
|
+
/**
|
|
5
|
+
* Decides a request against one RRset. A critical unknown tag refuses before anything
|
|
6
|
+
* else; for a wildcard domain any `issuewild` replaces every `issue`; issuers match
|
|
7
|
+
* exactly, case-insensitively and without the trailing dot. Reserved flag bits are ignored.
|
|
8
|
+
*
|
|
9
|
+
* @param records - The relevant RRset, such as `findRelevantCaa`'s records or a zone file's.
|
|
10
|
+
* @param request - The domain, the CA's identifiers and, optionally, the RFC 8657 account and method.
|
|
11
|
+
* @returns Whether the RRset allows the request, and which property or issuers decided it.
|
|
12
|
+
* @example evaluateCaa([{ type: "CAA", flags: 0, critical: false, tag: "issue", value: "pki.goog" }], { domain: "example.com", issuer: "letsencrypt.org" }) // { allowed: false, reason: "not-authorized", issuers: ["pki.goog"] }
|
|
13
|
+
*/
|
|
14
|
+
export function evaluateCaa(records, request) {
|
|
15
|
+
if (records.length === 0)
|
|
16
|
+
return { allowed: true, reason: "no-policy" };
|
|
17
|
+
let properties = records.map(parseCaaProperty);
|
|
18
|
+
let critical = properties.find((property) => property.kind === "unknown" && property.critical);
|
|
19
|
+
if (critical)
|
|
20
|
+
return { allowed: false, reason: "critical-tag", property: critical };
|
|
21
|
+
let applicable = applicableProperties(properties, request.domain);
|
|
22
|
+
if (applicable.length === 0)
|
|
23
|
+
return { allowed: true, reason: "unrestricted" };
|
|
24
|
+
let issuers = new Set([request.issuer].flat().map(normalizeIssuer));
|
|
25
|
+
let matching = applicable.filter((property) => property.issuer !== null && issuers.has(property.issuer));
|
|
26
|
+
let authorized = matching.find((property) => accountSatisfied(property, request) && methodSatisfied(property, request));
|
|
27
|
+
if (authorized)
|
|
28
|
+
return { allowed: true, reason: "authorized", property: authorized };
|
|
29
|
+
let [first] = matching;
|
|
30
|
+
if (first) {
|
|
31
|
+
let reason = accountSatisfied(first, request)
|
|
32
|
+
? "validation-method-mismatch"
|
|
33
|
+
: "account-mismatch";
|
|
34
|
+
return { allowed: false, reason, property: first };
|
|
35
|
+
}
|
|
36
|
+
let named = applicable.flatMap((property) => (property.issuer === null ? [] : [property.issuer]));
|
|
37
|
+
if (named.length > 0)
|
|
38
|
+
return { allowed: false, reason: "not-authorized", issuers: [...new Set(named)] };
|
|
39
|
+
return { allowed: false, reason: "forbidden" };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The properties that decide `domain` (RFC 8659 §4.3): `issuewild` for a wildcard name
|
|
43
|
+
* that has any, otherwise `issue`; a non-wildcard name never reads `issuewild`.
|
|
44
|
+
*/
|
|
45
|
+
function applicableProperties(properties, domain) {
|
|
46
|
+
let issue = properties.filter((property) => property.kind === "issue");
|
|
47
|
+
if (!domain.trim().startsWith("*."))
|
|
48
|
+
return issue;
|
|
49
|
+
let wild = properties.filter((property) => property.kind === "issuewild");
|
|
50
|
+
return wild.length > 0 ? wild : issue;
|
|
51
|
+
}
|
|
52
|
+
/** Folds a CA identifier the way `parseCaaProperty` folds an issuer. */
|
|
53
|
+
function normalizeIssuer(issuer) {
|
|
54
|
+
let lower = issuer.trim().toLowerCase();
|
|
55
|
+
return lower.endsWith(".") ? lower.slice(0, -1) : lower;
|
|
56
|
+
}
|
|
57
|
+
/** The values of every parameter named `key`, in published order. */
|
|
58
|
+
function parameterValues(property, key) {
|
|
59
|
+
return property.parameters
|
|
60
|
+
.filter((parameter) => parameter.key === key)
|
|
61
|
+
.map((parameter) => parameter.value);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* RFC 8657 §3: one `accounturi` binds the property to that account, and two or more
|
|
65
|
+
* make it unsatisfiable. A request with no `accountUri` passes a single binding.
|
|
66
|
+
*/
|
|
67
|
+
function accountSatisfied(property, request) {
|
|
68
|
+
let accounts = parameterValues(property, "accounturi");
|
|
69
|
+
if (accounts.length === 0)
|
|
70
|
+
return true;
|
|
71
|
+
if (accounts.length > 1)
|
|
72
|
+
return false;
|
|
73
|
+
return request.accountUri === undefined || accounts[0] === request.accountUri;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* RFC 8657 §4: `validationmethods` binds the property to the listed methods. An empty,
|
|
77
|
+
* malformed or repeated list is unsatisfiable; a request with no method passes a valid list.
|
|
78
|
+
*/
|
|
79
|
+
function methodSatisfied(property, request) {
|
|
80
|
+
let lists = parameterValues(property, "validationmethods");
|
|
81
|
+
if (lists.length === 0)
|
|
82
|
+
return true;
|
|
83
|
+
let [list] = lists;
|
|
84
|
+
if (lists.length > 1 || list === undefined || !METHOD_LIST.test(list))
|
|
85
|
+
return false;
|
|
86
|
+
return (request.validationMethod === undefined || list.split(",").includes(request.validationMethod));
|
|
87
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Predicts whether a CA may issue for a name: the relevant RRset found over DoH, then
|
|
3
|
+
* decided with no further I/O. Every failure, a broken lookup included, reads as
|
|
4
|
+
* `allowed: false`, because that is what a CA does with it (RFC 8659 §6).
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { DoH } from "../types.js";
|
|
10
|
+
import type { CAA } from "./types.js";
|
|
11
|
+
/**
|
|
12
|
+
* Whether a CA may issue for `request.domain`. A failing query answers `lookup-failed`
|
|
13
|
+
* with the name that failed, and an unparsable record in the set answers `unreadable`,
|
|
14
|
+
* both refused. The verdict predicts what the CA's own lookup finds if the zone serves the same.
|
|
15
|
+
*
|
|
16
|
+
* @param request - The domain, the CA's identifiers and, optionally, the RFC 8657 account and method.
|
|
17
|
+
* @param options - Passed to every lookup, so `resolver: GOOGLE` confirms through a second resolver.
|
|
18
|
+
* @returns The verdict and, unless the lookup failed, the RRset that decided it.
|
|
19
|
+
* @example let verdict = await mayIssue({ domain: "*.example.com", issuer: "letsencrypt.org" });
|
|
20
|
+
*/
|
|
21
|
+
export declare function mayIssue(request: CAA.Request, options?: DoH.ResolveOptions): Promise<CAA.Verdict>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { evaluateCaa } from "./evaluate.js";
|
|
2
|
+
import { climbCaa } from "./relevant-set.js";
|
|
3
|
+
/**
|
|
4
|
+
* Whether a CA may issue for `request.domain`. A failing query answers `lookup-failed`
|
|
5
|
+
* with the name that failed, and an unparsable record in the set answers `unreadable`,
|
|
6
|
+
* both refused. The verdict predicts what the CA's own lookup finds if the zone serves the same.
|
|
7
|
+
*
|
|
8
|
+
* @param request - The domain, the CA's identifiers and, optionally, the RFC 8657 account and method.
|
|
9
|
+
* @param options - Passed to every lookup, so `resolver: GOOGLE` confirms through a second resolver.
|
|
10
|
+
* @returns The verdict and, unless the lookup failed, the RRset that decided it.
|
|
11
|
+
* @example let verdict = await mayIssue({ domain: "*.example.com", issuer: "letsencrypt.org" });
|
|
12
|
+
*/
|
|
13
|
+
export async function mayIssue(request, options) {
|
|
14
|
+
let relevant = await climbCaa(request.domain, options);
|
|
15
|
+
if ("error" in relevant)
|
|
16
|
+
return { allowed: false, reason: "lookup-failed", ...relevant };
|
|
17
|
+
let [unreadable] = relevant.unparsed;
|
|
18
|
+
if (unreadable)
|
|
19
|
+
return { allowed: false, reason: "unreadable", record: unreadable, relevant };
|
|
20
|
+
return { ...evaluateCaa(relevant.records, request), relevant };
|
|
21
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads what one CAA record asks of a CA: the issuer and parameters of an `issue` or
|
|
3
|
+
* `issuewild` value under RFC 8659 §4.2's grammar, the report URL of an `iodef`, and any
|
|
4
|
+
* other tag kept as is, so a policy decision works on meanings instead of strings.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { CAA } from "./types.js";
|
|
10
|
+
/**
|
|
11
|
+
* Reads what one CAA record asks of a CA. An `issue` value that fails the grammar reads
|
|
12
|
+
* as `issuer: null, malformed: true`, which forbids issuance as RFC 8659 says it does;
|
|
13
|
+
* issuers are lowercased without a trailing dot, parameters are kept as written.
|
|
14
|
+
*
|
|
15
|
+
* @param record - The record's data, from `resolve` or `parseRecordData`.
|
|
16
|
+
* @returns The property, with `kind: "unknown"` for any tag other than `issue`, `issuewild` and `iodef`.
|
|
17
|
+
* @example parseCaaProperty({ type: "CAA", flags: 0, critical: false, tag: "issue", value: ";" }) // { kind: "issue", critical: false, issuer: null, malformed: false, parameters: [] }
|
|
18
|
+
*/
|
|
19
|
+
export declare function parseCaaProperty(record: CAA.Record): CAA.Property;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** RFC 8659 §4.2's `label`, which is also the grammar of a parameter `tag`. */
|
|
2
|
+
const LABEL = "[A-Za-z0-9](?:-*[A-Za-z0-9])*";
|
|
3
|
+
/** One `tag = value` parameter; a value is any printable ASCII but space and `;`. */
|
|
4
|
+
const PARAMETER = `${LABEL}[ \\t]*=[ \\t]*[\\x21-\\x3A\\x3C-\\x7E]*`;
|
|
5
|
+
/**
|
|
6
|
+
* The whole `issue-value`: an optional issuer domain (a trailing dot tolerated), then an
|
|
7
|
+
* optional `;` with an optional parameter list. Group 1 is the domain, group 2 the list.
|
|
8
|
+
*/
|
|
9
|
+
const ISSUE_VALUE = new RegExp(`^[ \\t]*(?:(${LABEL}(?:\\.${LABEL})*)\\.?[ \\t]*)?` +
|
|
10
|
+
`(?:;[ \\t]*(?:(${PARAMETER}(?:[ \\t]*;[ \\t]*${PARAMETER})*)[ \\t]*)?)?$`);
|
|
11
|
+
/** The URL schemes RFC 8659 §4.4 allows an `iodef` to report to. */
|
|
12
|
+
const IODEF_SCHEMES = new Set(["mailto:", "http:", "https:"]);
|
|
13
|
+
/**
|
|
14
|
+
* Reads what one CAA record asks of a CA. An `issue` value that fails the grammar reads
|
|
15
|
+
* as `issuer: null, malformed: true`, which forbids issuance as RFC 8659 says it does;
|
|
16
|
+
* issuers are lowercased without a trailing dot, parameters are kept as written.
|
|
17
|
+
*
|
|
18
|
+
* @param record - The record's data, from `resolve` or `parseRecordData`.
|
|
19
|
+
* @returns The property, with `kind: "unknown"` for any tag other than `issue`, `issuewild` and `iodef`.
|
|
20
|
+
* @example parseCaaProperty({ type: "CAA", flags: 0, critical: false, tag: "issue", value: ";" }) // { kind: "issue", critical: false, issuer: null, malformed: false, parameters: [] }
|
|
21
|
+
*/
|
|
22
|
+
export function parseCaaProperty(record) {
|
|
23
|
+
let tag = record.tag.toLowerCase();
|
|
24
|
+
let critical = record.critical;
|
|
25
|
+
if (tag === "issue" || tag === "issuewild") {
|
|
26
|
+
return { kind: tag, critical, ...readIssueValue(record.value) };
|
|
27
|
+
}
|
|
28
|
+
if (tag === "iodef")
|
|
29
|
+
return { kind: "iodef", critical, url: readIodefUrl(record.value) };
|
|
30
|
+
return { kind: "unknown", critical, tag, value: record.value };
|
|
31
|
+
}
|
|
32
|
+
/** The issuer and parameters of an `issue` value, or the malformed reading when the grammar fails. */
|
|
33
|
+
function readIssueValue(value) {
|
|
34
|
+
let match = ISSUE_VALUE.exec(value);
|
|
35
|
+
if (!match)
|
|
36
|
+
return { issuer: null, malformed: true, parameters: [] };
|
|
37
|
+
let parameters = (match[2] ?? "")
|
|
38
|
+
.split(";")
|
|
39
|
+
.filter((parameter) => parameter.trim().length > 0)
|
|
40
|
+
.map((parameter) => {
|
|
41
|
+
let separator = parameter.indexOf("=");
|
|
42
|
+
return {
|
|
43
|
+
key: parameter.slice(0, separator).trim(),
|
|
44
|
+
value: parameter.slice(separator + 1).trim(),
|
|
45
|
+
};
|
|
46
|
+
});
|
|
47
|
+
return { issuer: match[1]?.toLowerCase() ?? null, malformed: false, parameters };
|
|
48
|
+
}
|
|
49
|
+
/** The `iodef` value when it is a URL in one of RFC 8659's schemes, `null` otherwise. */
|
|
50
|
+
function readIodefUrl(value) {
|
|
51
|
+
let trimmed = value.trim();
|
|
52
|
+
let url = URL.parse(trimmed);
|
|
53
|
+
return url && IODEF_SCHEMES.has(url.protocol) ? trimmed : null;
|
|
54
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finds the CAA RRset RFC 8659 §3 applies to a name: the domain's own, or the nearest
|
|
3
|
+
* ancestor's below the root, one query per label and stopping at the first non-empty
|
|
4
|
+
* answer, so a domain publishing at its apex costs one or two lookups.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { Result } from "@sdxc/result";
|
|
10
|
+
import type { DoHError } from "../errors.js";
|
|
11
|
+
import type { DoH } from "../types.js";
|
|
12
|
+
import type { CAA } from "./types.js";
|
|
13
|
+
/** A climb that ended on a failing query, with the name that failed and the names asked so far. */
|
|
14
|
+
export interface ClimbFailure {
|
|
15
|
+
name: string;
|
|
16
|
+
queried: string[];
|
|
17
|
+
error: DoHError;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Finds the RRset a CA reads for `domain`. NODATA and NXDOMAIN move the climb to the
|
|
21
|
+
* parent; any other failure ends it, since a CA would not issue past it. Aliases are the
|
|
22
|
+
* resolver's: records reached through a CNAME are the target's, and `chain` says so.
|
|
23
|
+
*
|
|
24
|
+
* @param domain - The name, a leading `*.` allowed; an internationalized name must already be punycode.
|
|
25
|
+
* @param options - Passed to every `resolve` in the climb.
|
|
26
|
+
* @returns The relevant RRset, empty with `name: null` when no ancestor publishes one.
|
|
27
|
+
* @example await findRelevantCaa("shop.example.com") // success({ name: "example.com", records: [...], queried: ["shop.example.com", "example.com"], ... })
|
|
28
|
+
*/
|
|
29
|
+
export declare function findRelevantCaa(domain: string, options?: DoH.ResolveOptions): Promise<Result<CAA.RelevantSet, DoHError>>;
|
|
30
|
+
/**
|
|
31
|
+
* The climb behind `findRelevantCaa`, reporting a failure with the name that failed so a
|
|
32
|
+
* verdict can say where DNS broke. `authenticated` is the AD flag of every answer ANDed,
|
|
33
|
+
* NXDOMAIN included, because an unvalidated empty step is where a suppressed record hides.
|
|
34
|
+
*/
|
|
35
|
+
export declare function climbCaa(domain: string, options?: DoH.ResolveOptions): Promise<CAA.RelevantSet | ClimbFailure>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { failure, isFailure, success } from "@sdxc/result";
|
|
2
|
+
import { NameNotFoundError } from "../errors.js";
|
|
3
|
+
import { resolve } from "../resolve.js";
|
|
4
|
+
/**
|
|
5
|
+
* Finds the RRset a CA reads for `domain`. NODATA and NXDOMAIN move the climb to the
|
|
6
|
+
* parent; any other failure ends it, since a CA would not issue past it. Aliases are the
|
|
7
|
+
* resolver's: records reached through a CNAME are the target's, and `chain` says so.
|
|
8
|
+
*
|
|
9
|
+
* @param domain - The name, a leading `*.` allowed; an internationalized name must already be punycode.
|
|
10
|
+
* @param options - Passed to every `resolve` in the climb.
|
|
11
|
+
* @returns The relevant RRset, empty with `name: null` when no ancestor publishes one.
|
|
12
|
+
* @example await findRelevantCaa("shop.example.com") // success({ name: "example.com", records: [...], queried: ["shop.example.com", "example.com"], ... })
|
|
13
|
+
*/
|
|
14
|
+
export async function findRelevantCaa(domain, options) {
|
|
15
|
+
let climbed = await climbCaa(domain, options);
|
|
16
|
+
return "error" in climbed ? failure(climbed.error) : success(climbed);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The climb behind `findRelevantCaa`, reporting a failure with the name that failed so a
|
|
20
|
+
* verdict can say where DNS broke. `authenticated` is the AD flag of every answer ANDed,
|
|
21
|
+
* NXDOMAIN included, because an unvalidated empty step is where a suppressed record hides.
|
|
22
|
+
*/
|
|
23
|
+
export async function climbCaa(domain, options) {
|
|
24
|
+
let queried = [];
|
|
25
|
+
let authenticated = true;
|
|
26
|
+
for (let name of candidateNames(domain)) {
|
|
27
|
+
queried.push(name);
|
|
28
|
+
let answer = await resolve(name, "CAA", options);
|
|
29
|
+
if (isFailure(answer)) {
|
|
30
|
+
if (!(answer.error instanceof NameNotFoundError))
|
|
31
|
+
return { name, queried, error: answer.error };
|
|
32
|
+
authenticated &&= answer.error.authenticated;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
authenticated &&= answer.data.authenticated;
|
|
36
|
+
let { records, unparsed, chain } = answer.data;
|
|
37
|
+
if (records.length > 0 || unparsed.length > 0)
|
|
38
|
+
return { name, records, unparsed, chain, queried, authenticated };
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
name: null,
|
|
42
|
+
records: [],
|
|
43
|
+
unparsed: [],
|
|
44
|
+
chain: [],
|
|
45
|
+
queried,
|
|
46
|
+
authenticated: authenticated && queried.length > 0,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The domain and each ancestor below the root, nearest first. The domain is lowercased,
|
|
51
|
+
* its trailing dot and a leading `*.` dropped, since RFC 8659 computes `*.X`'s set at `X`.
|
|
52
|
+
*/
|
|
53
|
+
function candidateNames(domain) {
|
|
54
|
+
let name = domain.trim().toLowerCase();
|
|
55
|
+
if (name.endsWith("."))
|
|
56
|
+
name = name.slice(0, -1);
|
|
57
|
+
if (name.startsWith("*."))
|
|
58
|
+
name = name.slice(2);
|
|
59
|
+
let labels = name.split(".").filter((label) => label.length > 0);
|
|
60
|
+
return labels.map((_, index) => labels.slice(index).join("."));
|
|
61
|
+
}
|