@crewhaus/tool-notify 0.7.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.
@@ -0,0 +1,179 @@
1
+ export type NormalizedName = {
2
+ readonly ok: true;
3
+ readonly name: string;
4
+ } | {
5
+ readonly ok: false;
6
+ readonly reason: string;
7
+ };
8
+ /**
9
+ * Canonicalise a domain into the exact name that will be queried.
10
+ *
11
+ * The value that comes back is the value every later step uses - the
12
+ * allow-list check, the `_dmarc.` and `._domainkey.` names, and the result
13
+ * the caller reads. Checking one spelling and querying another is how a gate
14
+ * ends up guarding a name nobody asked for.
15
+ *
16
+ * `domainToASCII` does the IDNA half (`münchen.de` is queried as
17
+ * `xn--mnchen-3ya.de`, which is the only form a resolver understands) and
18
+ * nothing else: it returns `a..b.com`, `-bad.com` and a trailing dot
19
+ * unchanged, and it does NOT lowercase ASCII. So the label grammar is
20
+ * checked afterwards, on its output, rather than assumed from it.
21
+ */
22
+ export declare function normalizeDomain(raw: string): NormalizedName;
23
+ /**
24
+ * Canonicalise a DKIM selector.
25
+ *
26
+ * A selector is `sub-domain *("." sub-domain)` (RFC 6376 section 3.1), so
27
+ * dots are legal and the whole thing becomes part of a name this package
28
+ * queries. That makes it caller-controlled input on a DNS name, which is a
29
+ * channel: the label grammar and the length cap hold it to the shape a
30
+ * selector actually has, and the domain it is attached to is allow-listed
31
+ * separately, so reaching a resolver the operator does not run takes their
32
+ * own domain first.
33
+ */
34
+ export declare function normalizeSelector(raw: string): NormalizedName;
35
+ /**
36
+ * One TXT record from its character-strings.
37
+ *
38
+ * DNS splits a TXT record into chunks of at most 255 octets and a reader
39
+ * MUST concatenate them with nothing in between (RFC 7208 section 3.3, RFC
40
+ * 6376 section 3.6.2.1). Joining with a space - or reading only the first
41
+ * chunk - is the classic way to mis-read a DKIM record, because an RSA key
42
+ * is always longer than 255 characters and so is ALWAYS split.
43
+ */
44
+ export declare function joinTxtChunks(chunks: readonly string[]): string;
45
+ export type SpfMechanism = {
46
+ /** `+`, `-`, `~` or `?`. Absent in the record means `+` (RFC 7208 4.6.2). */
47
+ readonly qualifier: "+" | "-" | "~" | "?";
48
+ readonly kind: string;
49
+ readonly value?: string;
50
+ };
51
+ export type SpfModifier = {
52
+ readonly name: string;
53
+ readonly value: string;
54
+ };
55
+ export type SpfRecord = {
56
+ readonly record: string;
57
+ readonly mechanisms: readonly SpfMechanism[];
58
+ readonly modifiers: readonly SpfModifier[];
59
+ /** The `all` mechanism as written, e.g. `-all`. Null when there is none. */
60
+ readonly all: string | null;
61
+ /**
62
+ * DNS-querying terms in THIS record. A lower bound on the RFC 7208 4.6.4
63
+ * limit of ten, never the total: that limit counts the whole evaluation,
64
+ * including everything each `include:` pulls in.
65
+ */
66
+ readonly dnsTermsHere: number;
67
+ readonly notes: readonly string[];
68
+ };
69
+ export type SpfAnswer = {
70
+ readonly status: "found";
71
+ readonly parsed: SpfRecord;
72
+ } | {
73
+ readonly status: "absent";
74
+ } | {
75
+ readonly status: "multiple";
76
+ readonly records: readonly string[];
77
+ } | {
78
+ readonly status: "unreadable";
79
+ readonly record: string;
80
+ readonly reason: string;
81
+ };
82
+ /**
83
+ * Pick the domain's SPF record out of its TXT records and parse it.
84
+ *
85
+ * Two records is not "the first one wins": RFC 7208 4.5 says a receiver
86
+ * returns permerror, which means every sender fails, which is worse than
87
+ * publishing nothing. So it is its own answer rather than a note on one of
88
+ * them.
89
+ */
90
+ export declare function parseSpf(records: readonly string[]): SpfAnswer;
91
+ export type Tags = Readonly<Record<string, string>>;
92
+ export type TagParse = {
93
+ readonly ok: true;
94
+ readonly tags: Tags;
95
+ readonly order: readonly string[];
96
+ } | {
97
+ readonly ok: false;
98
+ readonly reason: string;
99
+ };
100
+ /**
101
+ * A `tag=value; tag=value` record (RFC 6376 section 3.2, which DMARC reuses).
102
+ *
103
+ * Whitespace around a tag and its value is not part of either, an empty
104
+ * segment after the last `;` is legal, and a repeated tag makes the record
105
+ * invalid rather than "last one wins" - which is why a repeat is reported
106
+ * instead of silently resolved.
107
+ */
108
+ export declare function parseTagValue(record: string): TagParse;
109
+ export type DmarcRecord = {
110
+ readonly record: string;
111
+ readonly tags: Tags;
112
+ /** The requested policy, verbatim. `null` when the record omits `p=`. */
113
+ readonly policy: string | null;
114
+ readonly subdomainPolicy: string | null;
115
+ /** `pct=` as written; absent means 100 (RFC 7489 section 6.3). */
116
+ readonly percent: string | null;
117
+ readonly notes: readonly string[];
118
+ };
119
+ export type DmarcAnswer = {
120
+ readonly status: "found";
121
+ readonly parsed: DmarcRecord;
122
+ } | {
123
+ readonly status: "absent";
124
+ } | {
125
+ readonly status: "multiple";
126
+ readonly records: readonly string[];
127
+ } | {
128
+ readonly status: "unreadable";
129
+ readonly record: string;
130
+ readonly reason: string;
131
+ };
132
+ export declare function parseDmarc(records: readonly string[]): DmarcAnswer;
133
+ export type DkimKey = {
134
+ readonly kind: "rsa";
135
+ readonly bits: number;
136
+ } | {
137
+ readonly kind: "ed25519";
138
+ readonly bytes: number;
139
+ } | {
140
+ readonly kind: "unreadable";
141
+ readonly reason: string;
142
+ };
143
+ export type DkimRecord = {
144
+ readonly record: string;
145
+ readonly tags: Tags;
146
+ /** `k=`, defaulting to rsa as RFC 6376 section 3.6.1 does. */
147
+ readonly keyType: string;
148
+ /**
149
+ * True when `p=` is present and empty, which RFC 6376 3.6.1 defines as a
150
+ * REVOKED key - a different fact from a selector that was never published.
151
+ */
152
+ readonly revoked: boolean;
153
+ readonly key: DkimKey | null;
154
+ readonly notes: readonly string[];
155
+ };
156
+ export type DkimAnswer = {
157
+ readonly status: "found";
158
+ readonly parsed: DkimRecord;
159
+ } | {
160
+ readonly status: "absent";
161
+ } | {
162
+ readonly status: "multiple";
163
+ readonly records: readonly string[];
164
+ } | {
165
+ readonly status: "unreadable";
166
+ readonly record: string;
167
+ readonly reason: string;
168
+ };
169
+ /**
170
+ * What the key in a DKIM record IS - never whether a signature verifies.
171
+ *
172
+ * The distinction is worth keeping in the type: a 512-bit key and a revoked
173
+ * one are facts about the record that a receiver acts on, while "this
174
+ * message's signature is good" needs canonicalisation, header selection and
175
+ * body hashing this package does not implement. A half-built verifier that
176
+ * can answer "pass" is worse than no verifier.
177
+ */
178
+ export declare function inspectDkimKey(keyType: string, rawP: string): DkimKey;
179
+ export declare function parseDkim(records: readonly string[]): DkimAnswer;
@@ -0,0 +1,431 @@
1
+ /**
2
+ * Reading the three TXT records a sending domain publishes about itself.
3
+ *
4
+ * Everything here is pure: a record arrives as the string DNS handed back
5
+ * and leaves as parsed facts. The lookups themselves live in `../net`, next
6
+ * to the rest of the outbound posture, so a bug in tag parsing reads as a
7
+ * failing unit rather than as a failing tool call.
8
+ *
9
+ * Three rules shape all of it.
10
+ *
11
+ * 1. **Report what the record says, never a score.** "no DMARC record" and
12
+ * "a DMARC record with p=none" are different facts about a domain, and
13
+ * the second one is a deliberate choice somebody made. A single number
14
+ * hides which of the two you are looking at, and hides it in exactly
15
+ * the case where the answer decides what to do next. So the output is
16
+ * the tags, verbatim, plus notes that state what the RFC says a
17
+ * receiver does with them.
18
+ * 2. **A record this cannot parse is not a record that says no.** Every
19
+ * shape here has an "unreadable, because ..." answer distinct from
20
+ * "absent". Reporting a corrupt DKIM record as a missing one sends
21
+ * somebody looking in the wrong place.
22
+ * 3. **Nothing here verifies a signature.** `parseDkim` reports what the
23
+ * KEY record says - its type, its size, whether it has been revoked -
24
+ * and never whether a message's DKIM signature validates. That needs
25
+ * canonicalisation, header selection and body hashing this package does
26
+ * not implement, and a partial signature check that can return "pass"
27
+ * is a security control that controls nothing.
28
+ */
29
+ import { Buffer } from "node:buffer";
30
+ import { createPublicKey } from "node:crypto";
31
+ import { domainToASCII } from "node:url";
32
+ // ---------------------------------------------------------------------------
33
+ // names
34
+ // ---------------------------------------------------------------------------
35
+ /** Longest DNS name, and longest label, in octets (RFC 1035 section 2.3.4). */
36
+ const MAX_NAME_LENGTH = 253;
37
+ /** A letter-digit-hyphen label that neither starts nor ends with a hyphen. */
38
+ const LDH_LABEL = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/;
39
+ /** A TLD: at least two characters, starting with a letter. `xn--…` qualifies. */
40
+ const TLD = /^[A-Za-z][A-Za-z0-9-]{1,62}$/;
41
+ /**
42
+ * Canonicalise a domain into the exact name that will be queried.
43
+ *
44
+ * The value that comes back is the value every later step uses - the
45
+ * allow-list check, the `_dmarc.` and `._domainkey.` names, and the result
46
+ * the caller reads. Checking one spelling and querying another is how a gate
47
+ * ends up guarding a name nobody asked for.
48
+ *
49
+ * `domainToASCII` does the IDNA half (`münchen.de` is queried as
50
+ * `xn--mnchen-3ya.de`, which is the only form a resolver understands) and
51
+ * nothing else: it returns `a..b.com`, `-bad.com` and a trailing dot
52
+ * unchanged, and it does NOT lowercase ASCII. So the label grammar is
53
+ * checked afterwards, on its output, rather than assumed from it.
54
+ */
55
+ export function normalizeDomain(raw) {
56
+ const trimmed = raw.trim().replace(/\.$/, "");
57
+ if (trimmed === "")
58
+ return { ok: false, reason: "the domain is empty" };
59
+ const ascii = domainToASCII(trimmed).toLowerCase();
60
+ if (ascii === "") {
61
+ return { ok: false, reason: `"${clip(raw)}" is not a domain name a resolver could be asked` };
62
+ }
63
+ if (ascii.length > MAX_NAME_LENGTH) {
64
+ return { ok: false, reason: `the domain is longer than the ${MAX_NAME_LENGTH}-octet limit` };
65
+ }
66
+ const labels = ascii.split(".");
67
+ if (labels.length < 2) {
68
+ return {
69
+ ok: false,
70
+ reason: `"${clip(ascii)}" is a single label, not a domain that can publish SPF or DMARC`,
71
+ };
72
+ }
73
+ for (const label of labels) {
74
+ if (!LDH_LABEL.test(label)) {
75
+ return { ok: false, reason: `"${clip(ascii)}" has a label that is not a valid DNS label` };
76
+ }
77
+ }
78
+ if (!TLD.test(labels[labels.length - 1])) {
79
+ // Also what refuses an IP literal: `1.2.3.4` parses as four labels whose
80
+ // last one is numeric, and an address does not publish TXT records.
81
+ return { ok: false, reason: `"${clip(ascii)}" does not end in a domain suffix` };
82
+ }
83
+ return { ok: true, name: ascii };
84
+ }
85
+ /** Longest DKIM selector accepted; the RFC bounds the name, not the selector. */
86
+ const MAX_SELECTOR_LENGTH = 100;
87
+ /**
88
+ * Canonicalise a DKIM selector.
89
+ *
90
+ * A selector is `sub-domain *("." sub-domain)` (RFC 6376 section 3.1), so
91
+ * dots are legal and the whole thing becomes part of a name this package
92
+ * queries. That makes it caller-controlled input on a DNS name, which is a
93
+ * channel: the label grammar and the length cap hold it to the shape a
94
+ * selector actually has, and the domain it is attached to is allow-listed
95
+ * separately, so reaching a resolver the operator does not run takes their
96
+ * own domain first.
97
+ */
98
+ export function normalizeSelector(raw) {
99
+ const trimmed = raw.trim().replace(/\.$/, "").toLowerCase();
100
+ if (trimmed === "")
101
+ return { ok: false, reason: "the selector is empty" };
102
+ if (trimmed.length > MAX_SELECTOR_LENGTH) {
103
+ return {
104
+ ok: false,
105
+ reason: `selector "${clip(trimmed)}" is longer than ${MAX_SELECTOR_LENGTH} characters`,
106
+ };
107
+ }
108
+ for (const label of trimmed.split(".")) {
109
+ if (!LDH_LABEL.test(label)) {
110
+ return {
111
+ ok: false,
112
+ reason: `selector "${clip(trimmed)}" is not a DKIM selector (letters, digits, hyphens and dots)`,
113
+ };
114
+ }
115
+ }
116
+ return { ok: true, name: trimmed };
117
+ }
118
+ /** Keep a refusal readable, and keep an over-long value out of a transcript. */
119
+ function clip(text) {
120
+ const oneLine = text.replace(/[\r\n\t]/g, " ");
121
+ return oneLine.length > 80 ? `${oneLine.slice(0, 77)}...` : oneLine;
122
+ }
123
+ /**
124
+ * One TXT record from its character-strings.
125
+ *
126
+ * DNS splits a TXT record into chunks of at most 255 octets and a reader
127
+ * MUST concatenate them with nothing in between (RFC 7208 section 3.3, RFC
128
+ * 6376 section 3.6.2.1). Joining with a space - or reading only the first
129
+ * chunk - is the classic way to mis-read a DKIM record, because an RSA key
130
+ * is always longer than 255 characters and so is ALWAYS split.
131
+ */
132
+ export function joinTxtChunks(chunks) {
133
+ return chunks.join("");
134
+ }
135
+ /** Terms that cost one of the ten DNS lookups RFC 7208 4.6.4 allows. */
136
+ const SPF_DNS_MECHANISMS = new Set(["include", "a", "mx", "ptr", "exists"]);
137
+ const SPF_MECHANISMS = new Set([
138
+ "all",
139
+ "include",
140
+ "a",
141
+ "mx",
142
+ "ptr",
143
+ "ip4",
144
+ "ip6",
145
+ "exists",
146
+ ]);
147
+ /** `v=spf1` then whitespace or nothing - RFC 7208 4.5, case-insensitive. */
148
+ function isSpfRecord(record) {
149
+ if (record.slice(0, 6).toLowerCase() !== "v=spf1")
150
+ return false;
151
+ const next = record.charAt(6);
152
+ return next === "" || next === " " || next === "\t";
153
+ }
154
+ /**
155
+ * Pick the domain's SPF record out of its TXT records and parse it.
156
+ *
157
+ * Two records is not "the first one wins": RFC 7208 4.5 says a receiver
158
+ * returns permerror, which means every sender fails, which is worse than
159
+ * publishing nothing. So it is its own answer rather than a note on one of
160
+ * them.
161
+ */
162
+ export function parseSpf(records) {
163
+ const spf = records.filter(isSpfRecord);
164
+ if (spf.length === 0)
165
+ return { status: "absent" };
166
+ if (spf.length > 1)
167
+ return { status: "multiple", records: [...spf].sort() };
168
+ const record = spf[0];
169
+ const mechanisms = [];
170
+ const modifiers = [];
171
+ const notes = [];
172
+ let all = null;
173
+ let dnsTermsHere = 0;
174
+ let sawAllAt = -1;
175
+ const terms = record.split(/[ \t]+/).filter((term) => term !== "");
176
+ for (const [index, term] of terms.entries()) {
177
+ if (index === 0)
178
+ continue; // the version token
179
+ // A modifier is `name=value` and carries no qualifier, so its shape is
180
+ // tested first; a mechanism can only reach this point with a `:` or `/`.
181
+ const modifier = /^([A-Za-z][A-Za-z0-9_.-]*)=(.*)$/.exec(term);
182
+ if (modifier !== null) {
183
+ modifiers.push({ name: modifier[1].toLowerCase(), value: modifier[2] });
184
+ continue;
185
+ }
186
+ const mechanism = /^([+\-~?])?([A-Za-z][A-Za-z0-9]*)(?:[:/](.*))?$/.exec(term);
187
+ if (mechanism === null) {
188
+ return {
189
+ status: "unreadable",
190
+ record,
191
+ reason: `"${clip(term)}" is neither a mechanism nor a modifier, so a receiver returns permerror for this record`,
192
+ };
193
+ }
194
+ const kind = mechanism[2].toLowerCase();
195
+ if (!SPF_MECHANISMS.has(kind)) {
196
+ return {
197
+ status: "unreadable",
198
+ record,
199
+ reason: `"${clip(term)}" is not an SPF mechanism, so a receiver returns permerror for this record`,
200
+ };
201
+ }
202
+ const qualifier = mechanism[1] ?? "+";
203
+ const rawValue = mechanism[3];
204
+ mechanisms.push({
205
+ qualifier,
206
+ kind,
207
+ ...(rawValue !== undefined && rawValue !== "" ? { value: rawValue } : {}),
208
+ });
209
+ if (SPF_DNS_MECHANISMS.has(kind))
210
+ dnsTermsHere += 1;
211
+ if (kind === "all" && all === null) {
212
+ all = `${qualifier}all`;
213
+ sawAllAt = mechanisms.length - 1;
214
+ }
215
+ }
216
+ const redirect = modifiers.find((m) => m.name === "redirect");
217
+ if (redirect !== undefined && all === null)
218
+ dnsTermsHere += 1;
219
+ if (all === null && redirect === undefined) {
220
+ notes.push("the record has neither an all mechanism nor a redirect modifier, so a sender it does not list gets neutral - the same result as no policy at all");
221
+ }
222
+ if (all !== null && redirect !== undefined) {
223
+ notes.push("the record carries both an all mechanism and a redirect modifier; RFC 7208 6.1 says redirect is ignored when all is present, so the redirect has no effect");
224
+ }
225
+ if (sawAllAt >= 0 && sawAllAt < mechanisms.length - 1) {
226
+ notes.push("mechanisms appear after all; evaluation stops at the first match, so those terms are never reached");
227
+ }
228
+ if (mechanisms.some((m) => m.kind === "ptr")) {
229
+ notes.push("the record uses ptr, which RFC 7208 5.5 deprecates and some receivers ignore");
230
+ }
231
+ if (dnsTermsHere > 10) {
232
+ notes.push(`this record alone carries ${dnsTermsHere} DNS-querying terms, over the limit of ten in RFC 7208 4.6.4 - a receiver returns permerror before it reaches the end`);
233
+ }
234
+ return {
235
+ status: "found",
236
+ parsed: { record, mechanisms, modifiers, all, dnsTermsHere, notes },
237
+ };
238
+ }
239
+ /**
240
+ * A `tag=value; tag=value` record (RFC 6376 section 3.2, which DMARC reuses).
241
+ *
242
+ * Whitespace around a tag and its value is not part of either, an empty
243
+ * segment after the last `;` is legal, and a repeated tag makes the record
244
+ * invalid rather than "last one wins" - which is why a repeat is reported
245
+ * instead of silently resolved.
246
+ */
247
+ export function parseTagValue(record) {
248
+ const tags = {};
249
+ const order = [];
250
+ for (const segment of record.split(";")) {
251
+ const trimmed = segment.trim();
252
+ if (trimmed === "")
253
+ continue;
254
+ const eq = trimmed.indexOf("=");
255
+ if (eq <= 0) {
256
+ return { ok: false, reason: `"${clip(trimmed)}" is not a tag=value pair` };
257
+ }
258
+ const name = trimmed.slice(0, eq).trim().toLowerCase();
259
+ if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(name)) {
260
+ return { ok: false, reason: `"${clip(name)}" is not a tag name` };
261
+ }
262
+ if (Object.hasOwn(tags, name)) {
263
+ return {
264
+ ok: false,
265
+ reason: `the tag "${name}" appears more than once, which makes the record invalid`,
266
+ };
267
+ }
268
+ tags[name] = trimmed.slice(eq + 1).trim();
269
+ order.push(name);
270
+ }
271
+ return { ok: true, tags, order };
272
+ }
273
+ const DMARC_POLICIES = new Set(["none", "quarantine", "reject"]);
274
+ /** `v=DMARC1` must be the FIRST tag - RFC 7489 6.6.3 discards anything else. */
275
+ function isDmarcRecord(record) {
276
+ const first = record.split(";")[0]?.trim() ?? "";
277
+ const eq = first.indexOf("=");
278
+ if (eq <= 0)
279
+ return false;
280
+ return (first.slice(0, eq).trim().toLowerCase() === "v" &&
281
+ first
282
+ .slice(eq + 1)
283
+ .trim()
284
+ .toUpperCase() === "DMARC1");
285
+ }
286
+ export function parseDmarc(records) {
287
+ const candidates = records.filter(isDmarcRecord);
288
+ if (candidates.length === 0)
289
+ return { status: "absent" };
290
+ // RFC 7489 6.6.3: more than one DMARC1 record and the domain is treated as
291
+ // having published NO policy. Reporting the first would describe a policy
292
+ // no receiver applies.
293
+ if (candidates.length > 1)
294
+ return { status: "multiple", records: [...candidates].sort() };
295
+ const record = candidates[0];
296
+ const parsed = parseTagValue(record);
297
+ if (!parsed.ok)
298
+ return { status: "unreadable", record, reason: parsed.reason };
299
+ const tags = parsed.tags;
300
+ const notes = [];
301
+ const rawPolicy = tags["p"];
302
+ const policy = rawPolicy === undefined ? null : rawPolicy.toLowerCase();
303
+ if (policy === null) {
304
+ notes.push("the record has no p= tag, which RFC 7489 6.3 requires - a receiver discards the record, so this domain has no DMARC policy in practice");
305
+ }
306
+ else if (!DMARC_POLICIES.has(policy)) {
307
+ notes.push(`p=${clip(policy)} is not one of none, quarantine or reject, so a receiver ignores it`);
308
+ }
309
+ else if (policy === "none") {
310
+ notes.push("p=none asks receivers to take no action on a failing message; it reports, it does not protect");
311
+ }
312
+ const percent = tags["pct"] ?? null;
313
+ if (percent !== null && percent !== "100") {
314
+ notes.push(`pct=${clip(percent)} applies the policy to that share of failing mail only`);
315
+ }
316
+ if (tags["rua"] === undefined && tags["ruf"] === undefined) {
317
+ notes.push("no rua= or ruf= address, so nobody receives the reports this policy generates");
318
+ }
319
+ const subdomain = tags["sp"];
320
+ return {
321
+ status: "found",
322
+ parsed: {
323
+ record,
324
+ tags,
325
+ policy,
326
+ subdomainPolicy: subdomain === undefined ? null : subdomain.toLowerCase(),
327
+ percent,
328
+ notes,
329
+ },
330
+ };
331
+ }
332
+ /** Base64 as the grammar defines it, before any decoder is handed the value. */
333
+ const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/;
334
+ /** Folding whitespace, which RFC 6376 3.2 allows inside a tag value. */
335
+ const FWS = /[ \t\r\n]/g;
336
+ /**
337
+ * What the key in a DKIM record IS - never whether a signature verifies.
338
+ *
339
+ * The distinction is worth keeping in the type: a 512-bit key and a revoked
340
+ * one are facts about the record that a receiver acts on, while "this
341
+ * message's signature is good" needs canonicalisation, header selection and
342
+ * body hashing this package does not implement. A half-built verifier that
343
+ * can answer "pass" is worse than no verifier.
344
+ */
345
+ export function inspectDkimKey(keyType, rawP) {
346
+ // The STRIPPED value is what gets decoded, and it is the same value the
347
+ // base64 grammar was checked against - checking one spelling and decoding
348
+ // another is how a record that is not base64 reaches a parser anyway.
349
+ const p = rawP.replace(FWS, "");
350
+ if (!BASE64.test(p)) {
351
+ return { kind: "unreadable", reason: "the p= tag is not base64" };
352
+ }
353
+ const bytes = Buffer.from(p, "base64");
354
+ if (bytes.byteLength === 0) {
355
+ return { kind: "unreadable", reason: "the p= tag decoded to no bytes" };
356
+ }
357
+ if (keyType === "ed25519") {
358
+ // RFC 8463 section 3: an ed25519 key is the RAW 32-byte public key, not
359
+ // a SubjectPublicKeyInfo - handing it to an SPKI parser would report a
360
+ // perfectly good published key as unreadable.
361
+ return bytes.byteLength === 32
362
+ ? { kind: "ed25519", bytes: bytes.byteLength }
363
+ : {
364
+ kind: "unreadable",
365
+ reason: `an ed25519 key is 32 bytes; this one is ${bytes.byteLength}`,
366
+ };
367
+ }
368
+ if (keyType !== "rsa") {
369
+ return {
370
+ kind: "unreadable",
371
+ reason: `k=${clip(keyType)} is a key type this tool does not read`,
372
+ };
373
+ }
374
+ try {
375
+ const key = createPublicKey({ key: bytes, format: "der", type: "spki" });
376
+ const bits = key.asymmetricKeyDetails?.modulusLength;
377
+ if (key.asymmetricKeyType !== "rsa" || typeof bits !== "number") {
378
+ return {
379
+ kind: "unreadable",
380
+ reason: `the p= tag holds a ${String(key.asymmetricKeyType)} key, but the record says k=rsa`,
381
+ };
382
+ }
383
+ return { kind: "rsa", bits };
384
+ }
385
+ catch {
386
+ return {
387
+ kind: "unreadable",
388
+ reason: "the p= tag is not a SubjectPublicKeyInfo an RSA key can be read from",
389
+ };
390
+ }
391
+ }
392
+ export function parseDkim(records) {
393
+ if (records.length === 0)
394
+ return { status: "absent" };
395
+ // A selector holds exactly one key record. Two is a misconfiguration a
396
+ // receiver resolves by trying each, so both are reported rather than one
397
+ // being picked.
398
+ if (records.length > 1)
399
+ return { status: "multiple", records: [...records].sort() };
400
+ const record = records[0];
401
+ const parsed = parseTagValue(record);
402
+ if (!parsed.ok)
403
+ return { status: "unreadable", record, reason: parsed.reason };
404
+ const tags = parsed.tags;
405
+ const notes = [];
406
+ const version = tags["v"];
407
+ if (version !== undefined && version.toUpperCase() !== "DKIM1") {
408
+ return {
409
+ status: "unreadable",
410
+ record,
411
+ reason: `v=${clip(version)} is not a DKIM key record; RFC 6376 3.6.1 requires v=DKIM1 when the tag is present`,
412
+ };
413
+ }
414
+ const keyType = (tags["k"] ?? "rsa").toLowerCase();
415
+ const rawP = tags["p"];
416
+ if (rawP === undefined) {
417
+ notes.push("the record has no p= tag, so it names no key and nothing signed with this selector can be checked against it");
418
+ }
419
+ const revoked = rawP !== undefined && rawP.replace(FWS, "") === "";
420
+ if (revoked) {
421
+ notes.push("p= is present and empty, which RFC 6376 3.6.1 defines as a revoked key - the selector exists and deliberately signs nothing");
422
+ }
423
+ if ((tags["t"] ?? "").split(":").includes("y")) {
424
+ notes.push("t=y puts the selector in testing mode, which tells receivers to treat a failed signature as if the message were unsigned");
425
+ }
426
+ const key = rawP === undefined || revoked ? null : inspectDkimKey(keyType, rawP);
427
+ if (key !== null && key.kind === "rsa" && key.bits < 1024) {
428
+ notes.push(`the key is ${key.bits} bits; RFC 8301 sets the floor for signing at 1024 and recommends 2048`);
429
+ }
430
+ return { status: "found", parsed: { record, tags, keyType, revoked, key, notes } };
431
+ }