@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.
- package/README.md +271 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +2167 -0
- package/dist/lib/blocks.d.ts +113 -0
- package/dist/lib/blocks.js +217 -0
- package/dist/lib/digest.d.ts +56 -0
- package/dist/lib/digest.js +140 -0
- package/dist/lib/dns-records.d.ts +179 -0
- package/dist/lib/dns-records.js +431 -0
- package/dist/lib/mime.d.ts +125 -0
- package/dist/lib/mime.js +618 -0
- package/dist/lib/preflight.d.ts +71 -0
- package/dist/lib/preflight.js +291 -0
- package/dist/lib/quiet.d.ts +59 -0
- package/dist/lib/quiet.js +217 -0
- package/dist/lib/ratelimit.d.ts +57 -0
- package/dist/lib/ratelimit.js +87 -0
- package/dist/lib/sign.d.ts +8 -0
- package/dist/lib/sign.js +39 -0
- package/dist/lib/template.d.ts +41 -0
- package/dist/lib/template.js +85 -0
- package/dist/net.d.ts +391 -0
- package/dist/net.js +1131 -0
- package/dist/paths.d.ts +42 -0
- package/dist/paths.js +158 -0
- package/dist/smtp.d.ts +56 -0
- package/dist/smtp.js +479 -0
- package/package.json +45 -0
|
@@ -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
|
+
}
|