@broberg/mail-identity 0.0.0-stage → 0.1.1

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 CHANGED
@@ -1,3 +1,232 @@
1
- # Temporary Holding Version
1
+ # @broberg/mail-identity
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Is this inbound mail **provably** from who the `From:` field claims?
4
+
5
+ Four pure functions, zero dependencies, no I/O. They read addresses and
6
+ `Authentication-Results` **the way the grammar does, not the way a regex does**.
7
+
8
+ ```bash
9
+ npm i @broberg/mail-identity
10
+ ```
11
+
12
+ ```ts
13
+ import { extractAddresses, addressesMatch, splitPlusTag, readAuthResults } from "@broberg/mail-identity";
14
+
15
+ extractAddresses('"cb@webhouse.dk" <attacker@evil.dk>'); // ['attacker@evil.dk']
16
+ addressesMatch(mail.from, ["cb@webhouse.dk"]); // false
17
+ splitPlusTag("buddy+whop@broberg.ai"); // { address: 'buddy@broberg.ai', tag: 'whop' }
18
+ readAuthResults(mail.headers["authentication-results"]); // { verdict: 'pass' | 'fail' | 'conflicted' | 'no-verdict', … }
19
+ ```
20
+
21
+ ## What it owns, and what it refuses to own
22
+
23
+ | | |
24
+ | --- | --- |
25
+ | **What an address is** | `extractAddresses` · `splitPlusTag` · `addressesMatch` |
26
+ | **What the auth header says** | `readAuthResults` |
27
+ | **What a match MEANS** | **not ours** |
28
+
29
+ The third row is the design. "May this mail wake an agent" is authorisation, and
30
+ it stays at your call-site where you can read it. Move it in here and every
31
+ consumer inherits a policy it cannot see, and a change to that policy ships
32
+ silently as a patch release.
33
+
34
+ ## Why this package exists
35
+
36
+ Two codebases independently wrote this same rule, and **both had holes in it,
37
+ found the same day, in each codebase separately.** The value here is not the
38
+ code — it is the cases in `test/`, every one of them an attack that **worked**
39
+ against a live implementation. Each looks correct until someone builds the
40
+ counter-example.
41
+
42
+ ## API
43
+
44
+ ### `extractAddresses(field): string[]`
45
+
46
+ Every address in a `From`/`To`/`Cc` field, lowercased. **A display name never
47
+ contributes one.**
48
+
49
+ ```ts
50
+ extractAddresses('Christian <cb@webhouse.dk>'); // ['cb@webhouse.dk']
51
+ extractAddresses('cb@webhouse.dk <attacker@evil.dk>'); // ['attacker@evil.dk']
52
+ extractAddresses('"x <buddy@broberg.ai>, y" <a@evil.dk>'); // ['a@evil.dk']
53
+ extractAddresses('"Broberg, Christian" <cb@webhouse.dk>'); // ['cb@webhouse.dk']
54
+ ```
55
+
56
+ The second line killed `from.includes(owner)`, which accepted the owner's
57
+ address written as display **text** — four call-paths reproduced it. The third
58
+ killed `field.split(',')`, which cuts a quoted display name in half *before*
59
+ quoting is understood; end-to-end it produced `isOwnerInstruction: true` for a
60
+ mail from `attacker@evil.dk` addressed to `victim@evil.dk`. The fourth is the
61
+ same bug's benign half — `Broberg, Christian` is just how a directory writes a
62
+ name, and a too-eager split turns it into a junk address.
63
+
64
+ ### `addressesMatch(field, known): boolean`
65
+
66
+ Equality on extracted addresses. **Never a substring, prefix or suffix.** Both
67
+ sides go through the same extraction and the same plus-tag normalisation, so
68
+ `known` may be bare addresses or full fields. An empty or unparseable field
69
+ matches nothing.
70
+
71
+ ### `splitPlusTag(address): { address, tag }`
72
+
73
+ ```ts
74
+ splitPlusTag("buddy+whop@broberg.ai"); // { address: 'buddy@broberg.ai', tag: 'whop' }
75
+ splitPlusTag("buddy@broberg.ai"); // { address: 'buddy@broberg.ai', tag: null }
76
+ ```
77
+
78
+ **The tag informs; the address proves.** An unknown tag must still match. A tag
79
+ able to *refuse* a match would be authorisation disguised as routing, and a typo
80
+ (`buddy+whopp@`) would make mail vanish silently. `+admin`, `+deploy`, `+urgent`
81
+ must never mean anything privileged — anyone can type them.
82
+
83
+ **Split after `extractAddresses`, never on a raw field.** The other way round
84
+ puts you back in the impersonation hole wearing a new hat.
85
+
86
+ ### `readAuthResults(header): AuthResults`
87
+
88
+ ```ts
89
+ type AuthVerdict = "pass" | "fail" | "conflicted" | "no-verdict";
90
+ ```
91
+
92
+ **Four outcomes, not three, and never two.**
93
+
94
+ | outcome | meaning |
95
+ | --- | --- |
96
+ | `pass` | a dmarc verdict exists and everything present passed |
97
+ | `fail` | at least one method's verdicts unambiguously did not pass |
98
+ | `conflicted` | one method carries **both** a passing and a failing verdict |
99
+ | `no-verdict` | nothing readable to judge by |
100
+
101
+ Also returned: `spf` / `dkim` / `dmarc` as read, `conflicted` (which methods
102
+ disagreed), and `reason` — always set, including on `pass`, because a caller
103
+ logging a decision needs the why.
104
+
105
+ ## Three things that will bite you
106
+
107
+ ### 1. A missing verdict is not a failure — and the inversion is the whole point
108
+
109
+ A genuine **self-sent** mail carries **no verdict at all**: the header is written
110
+ by the *receiving* server, and a mail from your own authenticated session never
111
+ crosses that boundary. An external **forgery** of the same `From:` **does** cross
112
+ it, gets a verdict, and that verdict **fails**.
113
+
114
+ So a gate demanding `'pass'` rejects precisely the mail the feature exists for,
115
+ while letting nothing extra in. Never collapse `no-verdict` into either
116
+ neighbour.
117
+
118
+ ### 2. The permissive outcome must never be your `default` branch
119
+
120
+ A new outcome silently lands in `default`. If `default` is the accepting one,
121
+ **adding an outcome widens your gate.**
122
+
123
+ This is not hypothetical. The first consumer hit it while adding the fourth
124
+ outcome *they had argued for*: `conflicted` fell into the `no-verdict` branch,
125
+ because that branch was the default — and the `no-verdict` branch accepts the
126
+ owner's own address (see #1). An injected verdict would have become the owner's
127
+ instruction. Yesterday's hole, reopened by their own cleanup, and **invisible to
128
+ every test of the reader.**
129
+
130
+ ```ts
131
+ import { readAuthResults, addressesMatch, type AuthVerdict } from "@broberg/mail-identity";
132
+
133
+ type Decision = "accept-owner" | "accept-external" | "reject";
134
+
135
+ function decide(verdict: AuthVerdict, fromOwner: boolean): Decision {
136
+ switch (verdict) {
137
+ case "pass":
138
+ return fromOwner ? "accept-owner" : "accept-external";
139
+ case "no-verdict":
140
+ return fromOwner ? "accept-owner" : "reject"; // permissive — never the default
141
+ case "fail":
142
+ return "reject";
143
+ case "conflicted":
144
+ return "reject"; // YOUR policy, on one findable line
145
+ default: {
146
+ const unhandled: never = verdict; // compile-time: a fifth outcome breaks the build
147
+ void unhandled;
148
+ return "reject"; // runtime: the REJECTING answer
149
+ }
150
+ }
151
+ }
152
+ ```
153
+
154
+ **Both layers, and neither is redundant.** `never` protects only the consumers
155
+ that compile — the input is an untrusted header, and a JavaScript consumer has
156
+ no type at all. A package that defends itself with `never` alone defends half its
157
+ consumers.
158
+
159
+ **And put the guard on the DECISION, not on the reader.** Asserting that
160
+ `readAuthResults` returns `'conflicted'` proves nothing about what you do with
161
+ it. The test that matters asserts the resulting *authorisation*. Ours is in
162
+ `test/consumer-trap.test.ts` — the snippet above, executable.
163
+
164
+ ### 3. `conflicted` is not "probably an attack"
165
+
166
+ RFC 8601 **Appendix B.6**, *"Service Provided, Multi-tiered Authentication
167
+ Done"* — the standard's own example of **normal operation**:
168
+
169
+ ```
170
+ dkim=pass reason="good signature" header.i=@mail-router.example.net;
171
+ dkim=fail reason="bad signature" header.i=@newyork.example.com
172
+ ```
173
+
174
+ Two signatures, one good, one bad, in one header. **Syntactically identical to a
175
+ duplicate-injection attack**, and not distinguishable from one by reading the
176
+ header. So this package does not try: it reports the disagreement and you decide.
177
+
178
+ Fail-closed is a perfectly good policy. It is just not ours to choose for
179
+ everyone — and if you pick it, know that you are condemning an RFC-documented
180
+ legal case, which is a trade, not a free win.
181
+
182
+ ## Notes from the corpus
183
+
184
+ - **The anchor is an allowlist.** A verdict must stand at the start of the header
185
+ or after a real separator (whitespace, `;`, `,` — RFC 8601's own). `\b` was the
186
+ original and a dot is a word boundary, so `\bdmarc=` matched inside
187
+ `header.dmarc=pass`. The denylist that replaced it left 27 printable characters
188
+ open, including a colon. The test sweeps the **entire** printable character set,
189
+ because a hand-written list of 9 found 3 of the 27.
190
+ - **Quoted strings and comments are blanked before matching.** They are two more
191
+ places a verdict cannot stand, and both manufactured verdicts in the prior
192
+ implementation: `dkim=pass reason="relayed dmarc=pass ok"` turned `no-verdict`
193
+ into `pass`. A space inside a quoted string is a legal separator to a regex and
194
+ no separator at all to the grammar. **That one sentence explains every parsing
195
+ bug this package was built to end**, and it is why the answer is a tokeniser
196
+ rather than another regex patch.
197
+ - **All verdicts are read, never the first.** "First wins" lets an injected
198
+ `dmarc=pass` drown out the real `dmarc=fail`.
199
+ - **An unambiguous fail beats a conflict.** A method where *no* verdict passed is
200
+ stronger evidence than a disagreement elsewhere. Backwards, and a genuine
201
+ rejection is downgraded to "cannot determine" — which a lenient consumer lets
202
+ through.
203
+ - **A header that does not parse to the end yields nothing.** An unterminated
204
+ quote or comment blanks every verdict after it while leaving earlier ones
205
+ standing; erasing a trailing `dmarc=fail` and keeping a leading `spf=pass` is
206
+ strictly useful to an attacker.
207
+ - **Read the topmost `Authentication-Results` only.** RFC 8601 B.5 shows two,
208
+ written by two MTAs, disagreeing. Concatenate every instance and you are asking
209
+ two machines one question and getting the disagreement you asked for.
210
+ - **The RFC cannot prove the accept path.** `dmarc=` appears **zero** times in RFC
211
+ 8601 — the three "DMARC" hits are all bibliography. Since `pass` requires a
212
+ dmarc verdict (it is the only method that binds `From:` to what passed), every
213
+ RFC example yields `no-verdict`, `fail` or `conflicted`. The RFC is the best
214
+ corpus in existence for the *parser* and contributes **no positive controls**.
215
+ That is pinned as a number in `test/rfc-8601-examples.test.ts`, so it breaks if
216
+ anyone adds a fixture believing otherwise.
217
+
218
+ ## Testing
219
+
220
+ ```bash
221
+ pnpm test # the suite
222
+ node test/mutations.mjs # revert each security decision, one at a time
223
+ ```
224
+
225
+ The mutation harness is the part worth copying. A green suite proves nothing on
226
+ its own; it checks that every reverted decision reddens a **different,
227
+ non-overlapping** set of tests. A mutation that reddens everything only proves
228
+ the suite runs, and one that reddens nothing means the decision is untested. It
229
+ found a real gap in this suite: two different defects reddened identical sets, so
230
+ a reader would have been sent to the wrong line.
231
+
232
+ MIT · part of the [broberg.ai](https://discovery.broberg.ai) shared inventory.
package/dist/index.cjs ADDED
@@ -0,0 +1,170 @@
1
+ 'use strict';
2
+
3
+ // src/index.ts
4
+ var METHODS = ["spf", "dkim", "dmarc"];
5
+ function maskQuotedAndComments(s) {
6
+ const out = s.split("");
7
+ let quoted = false;
8
+ let comment = 0;
9
+ for (let i = 0; i < s.length; i++) {
10
+ const c = s[i];
11
+ if ((quoted || comment > 0) && c === "\\" && i + 1 < s.length) {
12
+ out[i] = " ";
13
+ out[i + 1] = " ";
14
+ i++;
15
+ continue;
16
+ }
17
+ if (quoted) {
18
+ out[i] = " ";
19
+ if (c === '"') quoted = false;
20
+ continue;
21
+ }
22
+ if (comment > 0) {
23
+ if (c === "(") comment++;
24
+ else if (c === ")") comment--;
25
+ out[i] = " ";
26
+ continue;
27
+ }
28
+ if (c === '"') {
29
+ quoted = true;
30
+ out[i] = " ";
31
+ continue;
32
+ }
33
+ if (c === "(") {
34
+ comment++;
35
+ out[i] = " ";
36
+ continue;
37
+ }
38
+ }
39
+ return { masked: out.join(""), malformed: quoted || comment > 0 };
40
+ }
41
+ function normalise(s) {
42
+ const t = s.trim().toLowerCase();
43
+ return t.length > 0 ? t : null;
44
+ }
45
+ function addressOfPart(raw, masked, from, to) {
46
+ let open = -1;
47
+ let close = -1;
48
+ let depth = 0;
49
+ for (let i = from; i < to; i++) {
50
+ const c = masked[i];
51
+ if (c === "<") {
52
+ if (depth === 0) open = i;
53
+ depth++;
54
+ } else if (c === ">" && depth > 0) {
55
+ depth--;
56
+ if (depth === 0) {
57
+ close = i;
58
+ break;
59
+ }
60
+ }
61
+ }
62
+ if (open >= 0 && close > open) return normalise(raw.slice(open + 1, close));
63
+ if (open >= 0) return normalise(raw.slice(from, to));
64
+ return normalise(masked.slice(from, to));
65
+ }
66
+ function extractAddresses(field) {
67
+ if (!field) return [];
68
+ const { masked } = maskQuotedAndComments(field);
69
+ const out = [];
70
+ let start = 0;
71
+ let angle = 0;
72
+ const flush = (end) => {
73
+ const a = addressOfPart(field, masked, start, end);
74
+ if (a && a.includes("@")) out.push(a);
75
+ start = end + 1;
76
+ };
77
+ for (let i = 0; i < masked.length; i++) {
78
+ const c = masked[i];
79
+ if (c === "<") angle++;
80
+ else if (c === ">" && angle > 0) angle--;
81
+ else if (c === "," && angle === 0) flush(i);
82
+ }
83
+ flush(masked.length);
84
+ return out;
85
+ }
86
+ function splitPlusTag(address) {
87
+ const at = address.indexOf("@");
88
+ if (at < 0 || address.indexOf("@", at + 1) !== -1) return { address, tag: null };
89
+ const plus = address.indexOf("+");
90
+ if (plus < 0 || plus > at) return { address, tag: null };
91
+ const tag = address.slice(plus + 1, at);
92
+ return { address: address.slice(0, plus) + address.slice(at), tag: tag || null };
93
+ }
94
+ function addressesMatch(field, known) {
95
+ const present = new Set(extractAddresses(field).map((a) => splitPlusTag(a).address));
96
+ if (present.size === 0) return false;
97
+ const list = typeof known === "string" ? [known] : known;
98
+ return list.some(
99
+ (k) => extractAddresses(k).some((a) => present.has(splitPlusTag(a).address))
100
+ );
101
+ }
102
+ function readAuthResults(header) {
103
+ if (!header || !header.trim()) {
104
+ return {
105
+ verdict: "no-verdict",
106
+ conflicted: [],
107
+ reason: "no Authentication-Results header \u2014 the sender is unproven, which is not the same as disproven"
108
+ };
109
+ }
110
+ const { masked, malformed } = maskQuotedAndComments(header);
111
+ if (malformed) {
112
+ return {
113
+ verdict: "no-verdict",
114
+ conflicted: [],
115
+ reason: "unterminated quoted string or comment \u2014 the header could not be parsed to the end, and a verdict read out of the readable half would be a verdict an attacker chose the boundary of"
116
+ };
117
+ }
118
+ const found = {};
119
+ const conflicted = [];
120
+ for (const method of METHODS) {
121
+ const re = new RegExp(`(?<=^|[\\s;,])${method}\\s*(?:/\\s*\\d+\\s*)?=\\s*([a-z]+)`, "gi");
122
+ const verdicts = [...masked.matchAll(re)].map((m) => m[1].toLowerCase());
123
+ if (verdicts.length === 0) continue;
124
+ const passed = verdicts.filter((v) => v === "pass").length;
125
+ if (passed > 0 && passed < verdicts.length) conflicted.push(method);
126
+ found[method] = passed === verdicts.length ? "pass" : verdicts.find((v) => v !== "pass");
127
+ }
128
+ if (Object.keys(found).length === 0) {
129
+ return {
130
+ verdict: "no-verdict",
131
+ conflicted: [],
132
+ reason: "Authentication-Results was present but carried no readable spf/dkim/dmarc verdict"
133
+ };
134
+ }
135
+ const failed = METHODS.filter(
136
+ (m) => found[m] !== void 0 && found[m] !== "pass" && !conflicted.includes(m)
137
+ );
138
+ if (failed.length > 0) {
139
+ return {
140
+ verdict: "fail",
141
+ ...found,
142
+ conflicted,
143
+ reason: `rejected: ${failed.map((m) => `${m}=${found[m]}`).join(", ")}`
144
+ };
145
+ }
146
+ if (conflicted.length > 0) {
147
+ return {
148
+ verdict: "conflicted",
149
+ ...found,
150
+ conflicted,
151
+ reason: `contradictory verdicts for ${conflicted.join(", ")} \u2014 the header carries both a pass and a non-pass. This is legal multi-tiered authentication (RFC 8601 B.6) OR an injected verdict, and the header cannot tell you which. Your call.`
152
+ };
153
+ }
154
+ if (found.dmarc === void 0) {
155
+ return {
156
+ verdict: "no-verdict",
157
+ ...found,
158
+ conflicted,
159
+ reason: "no dmarc verdict \u2014 the From: field is not bound to whatever did pass"
160
+ };
161
+ }
162
+ return { verdict: "pass", ...found, conflicted, reason: "spf/dkim/dmarc pass" };
163
+ }
164
+
165
+ exports.addressesMatch = addressesMatch;
166
+ exports.extractAddresses = extractAddresses;
167
+ exports.readAuthResults = readAuthResults;
168
+ exports.splitPlusTag = splitPlusTag;
169
+ //# sourceMappingURL=index.cjs.map
170
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * @broberg/mail-identity — is this inbound mail provably from who the From:\n * field claims?\n *\n * Four pure functions, no I/O, no dependencies. They answer two questions and\n * refuse a third:\n *\n * WHAT IS AN ADDRESS? extractAddresses · splitPlusTag · addressesMatch\n * WHAT DOES THE HEADER SAY? readAuthResults\n * WHAT DOES A MATCH MEAN? NOT OURS. That is authorisation, and it stays\n * at the call-site where it can be read.\n *\n * The third line is the design. Moving an authorisation decision into a shared\n * dependency would build something worse than the drift it replaces: every\n * consumer inherits a policy it cannot see, and a change to that policy ships\n * silently as a patch release.\n *\n * ORIGIN: buddy and cardmem independently wrote this same rule, and BOTH had\n * holes in it, found the same day in each codebase separately. The value here is\n * not the code — it is the cases in test/, every one of them an attack that\n * WORKED against a live implementation. Each looks correct until someone builds\n * the counter-example.\n */\n\n/**\n * What the Authentication-Results header says. FOUR outcomes, not three, and\n * not two.\n *\n * pass a dmarc verdict exists and everything present passed\n * fail at least one method's verdicts unambiguously did not pass\n * conflicted one method carries BOTH a passing and a failing verdict\n * no-verdict nothing readable to judge by\n *\n * `conflicted` exists because RFC 8601 Appendix B.6 — \"Service Provided,\n * Multi-tiered Authentication Done\", the standard's own example of NORMAL\n * OPERATION — is SYNTACTICALLY IDENTICAL to a duplicate-injection attack:\n *\n * dkim=pass reason=\"good signature\" header.i=@mail-router.example.net;\n * dkim=fail reason=\"bad signature\" header.i=@newyork.example.com\n *\n * Two signatures, one good, one bad, in one header. Nothing in the header\n * distinguishes that from an attacker appending `dkim=pass`. So this package\n * does not try: it REPORTS the disagreement and the caller decides. Fail-closed\n * is a perfectly good policy — it is just not ours to choose for everyone.\n *\n * `no-verdict` must NEVER collapse into `pass`, and the inversion is why it\n * matters more than it looks: a genuine SELF-SENT mail has no verdict at all\n * (the header is written by the RECEIVING server, and a mail from your own\n * authenticated session never crosses that boundary), while an external forgery\n * HAS one and it FAILS. A gate demanding `pass` rejects precisely the mail the\n * feature exists for.\n */\nexport type AuthVerdict = \"pass\" | \"fail\" | \"conflicted\" | \"no-verdict\";\n\n/** The three methods that say something about the sending domain. */\nexport type AuthMethod = \"spf\" | \"dkim\" | \"dmarc\";\n\nconst METHODS: readonly AuthMethod[] = [\"spf\", \"dkim\", \"dmarc\"];\n\nexport interface AuthResults {\n verdict: AuthVerdict;\n /** Per-method result as read. `pass` only when every verdict for it passed. */\n spf?: string;\n dkim?: string;\n dmarc?: string;\n /** Methods carrying BOTH a passing and a failing verdict. Empty unless `conflicted`. */\n conflicted: readonly AuthMethod[];\n /** Always set, including on `pass` — a caller logging a decision needs the why. */\n reason: string;\n}\n\n/**\n * A view of `s` in which every quoted-string and every (nested) comment — and\n * their delimiters — is replaced by a SPACE, position for position.\n *\n * THIS IS THE WHOLE TRICK, and it is what a regex over the raw string cannot do.\n * Both grammars we parse (RFC 5322 address lists, RFC 8601 auth results) have\n * quoting, and in both a comment is defined to be whitespace. A regex has\n * neither notion, so it sees a legal separator wherever there is a space —\n * INCLUDING the spaces inside a quoted string. Measured against the prior\n * implementation, all three of these came from exactly that:\n *\n * 'Authentication-Results: …; dkim=pass reason=\"relayed dmarc=pass ok\"'\n * → no-verdict became PASS. Text inside a reason string became a verdict.\n *\n * 'Authentication-Results: …; dkim=pass (relayed dmarc=pass ok)'\n * → same, via a comment.\n *\n * 'To: \"x <buddy@broberg.ai>, y\" <attacker@evil.dk>'\n * → split-on-comma cut the quoted display name in half, and the half\n * carried an <addr> that was only ever decoration. A display name\n * contributed an address, which is the single property this package\n * exists to guarantee it cannot.\n *\n * Replacing with a space rather than DELETING is deliberate: deletion joins the\n * neighbours, so `dmar(x)c=pass` would become the verdict `dmarc=pass` out of\n * text that is not one. A comment is whitespace; blanking says exactly that.\n *\n * Unterminated quotes and comments blank the remainder of the string, and are\n * reported as `malformed` so a caller of this helper can refuse to read a\n * PARTIAL result out of a header it could not finish parsing. That distinction\n * is not pedantry — the qualifier values in an auth header (`smtp.mailfrom=`,\n * `header.i=`) come from the message, so an unterminated delimiter there would\n * blank every verdict AFTER it while leaving the earlier ones standing. Erasing\n * a trailing `dmarc=fail` and keeping a leading `spf=pass` is a strictly useful\n * outcome for an attacker, so a header that does not parse yields nothing.\n */\nfunction maskQuotedAndComments(s: string): { masked: string; malformed: boolean } {\n const out = s.split(\"\");\n let quoted = false;\n let comment = 0;\n\n for (let i = 0; i < s.length; i++) {\n const c = s[i]!;\n\n if ((quoted || comment > 0) && c === \"\\\\\" && i + 1 < s.length) {\n out[i] = \" \";\n out[i + 1] = \" \";\n i++;\n continue;\n }\n if (quoted) {\n out[i] = \" \";\n if (c === '\"') quoted = false;\n continue;\n }\n if (comment > 0) {\n if (c === \"(\") comment++;\n else if (c === \")\") comment--;\n out[i] = \" \";\n continue;\n }\n if (c === '\"') {\n quoted = true;\n out[i] = \" \";\n continue;\n }\n if (c === \"(\") {\n comment++;\n out[i] = \" \";\n continue;\n }\n }\n\n return { masked: out.join(\"\"), malformed: quoted || comment > 0 };\n}\n\n/** Lowercase + trim, or null when nothing is left. */\nfunction normalise(s: string): string | null {\n const t = s.trim().toLowerCase();\n return t.length > 0 ? t : null;\n}\n\n/**\n * The address of ONE address-list entry, given the raw field and its mask.\n *\n * Angle brackets are located in the MASKED view, so a `<` inside a quoted\n * display name is simply not there to be found — but the address itself is\n * sliced from the RAW string, so a quoted local part (`<\"odd name\"@example.com>`)\n * survives intact.\n */\nfunction addressOfPart(raw: string, masked: string, from: number, to: number): string | null {\n let open = -1;\n let close = -1;\n let depth = 0;\n\n for (let i = from; i < to; i++) {\n const c = masked[i]!;\n if (c === \"<\") {\n if (depth === 0) open = i;\n depth++;\n } else if (c === \">\" && depth > 0) {\n depth--;\n if (depth === 0) {\n close = i;\n break;\n }\n }\n }\n\n if (open >= 0 && close > open) return normalise(raw.slice(open + 1, close));\n\n // An UNTERMINATED '<' is malformed. Hand back the part as it stands rather\n // than guessing which half was meant to be the address — something that\n // matches nothing beats something that might match the owner.\n if (open >= 0) return normalise(raw.slice(from, to));\n\n // No angle brackets: the address is the bare text, which is the MASKED text —\n // so a comment such as `cb@webhouse.dk (Christian)` contributes nothing.\n return normalise(masked.slice(from, to));\n}\n\n/**\n * Every address in a From/To/Cc field. A DISPLAY NAME NEVER CONTRIBUTES ONE.\n *\n * 'Christian Broberg <cb@webhouse.dk>' → ['cb@webhouse.dk']\n * 'A <a@x.dk>, b@y.dk' → ['a@x.dk', 'b@y.dk']\n * '\"cb@webhouse.dk\" <attacker@evil.dk>' → ['attacker@evil.dk']\n * '\"x <buddy@broberg.ai>, y\" <a@evil.dk>' → ['a@evil.dk']\n * '\"Broberg, Christian\" <cb@webhouse.dk>' → ['cb@webhouse.dk']\n *\n * The first counter-example killed `from.includes(owner)`, which accepted the\n * owner's address written as DISPLAY TEXT — four call-paths reproduced it. The\n * second and third killed `field.split(',')`, which cuts a quoted display name\n * in half before quoting is understood. Both were live; neither was theoretical.\n *\n * Addresses are lowercased. Local parts are formally case-sensitive (RFC 5321),\n * but no provider we send through treats them that way, and NOT normalising\n * would mean `CB@webhouse.dk` fails an owner check that `cb@webhouse.dk` passes\n * — a comparison anyone can sidestep with the shift key.\n */\nexport function extractAddresses(field: string | null | undefined): string[] {\n if (!field) return [];\n // A malformed field is not rejected outright here: blanking already makes the\n // unparseable remainder match nothing, and an address list has no equivalent\n // of the partial-verdict problem — an address either equals a known one or it\n // does not.\n const { masked } = maskQuotedAndComments(field);\n\n const out: string[] = [];\n let start = 0;\n let angle = 0;\n\n const flush = (end: number): void => {\n const a = addressOfPart(field, masked, start, end);\n // 0.1.1 — a part with no '@' is not an address (buddy, 31/10: `No Address\n // Here` came back as ['no address here']). Text is not promoted to an\n // address because the field happened to contain no brackets.\n if (a && a.includes(\"@\")) out.push(a);\n start = end + 1;\n };\n\n for (let i = 0; i < masked.length; i++) {\n const c = masked[i]!;\n if (c === \"<\") angle++;\n else if (c === \">\" && angle > 0) angle--;\n else if (c === \",\" && angle === 0) flush(i);\n }\n flush(masked.length);\n\n return out;\n}\n\n/**\n * Split a plus-tag off an address.\n *\n * 'buddy+whop@broberg.ai' → { address: 'buddy@broberg.ai', tag: 'whop' }\n * 'buddy@broberg.ai' → { address: 'buddy@broberg.ai', tag: null }\n *\n * TWO RULES CARRY THE SECURITY, and they are the reason this is separate from\n * matching rather than folded into it:\n *\n * 1. THE TAG INFORMS, THE ADDRESS PROVES. An unknown tag must still match. A tag\n * able to REFUSE a match would be authorisation disguised as routing, and a\n * typo (`buddy+whopp@`) would make mail vanish silently. `+admin`, `+deploy`\n * and `+urgent` must never mean anything privileged — anyone can type them.\n *\n * 2. SPLIT AFTER extractAddresses(), NEVER on a raw field. Do it the other way\n * round and you are back in the impersonation hole wearing a new hat: a\n * display name must not contribute an address, and that property lives in the\n * extraction.\n *\n * An address with more than one '@' is malformed and is returned UNTOUCHED:\n * `buddy+x@evil.com@broberg.ai` must never be reshaped into something valid.\n *\n * `tag` is `null`, not `undefined`, so that a fixture written as\n * `{ address: 'x', tag: undefined }` cannot pass a deep-equality check against\n * `{ address: 'x' }`. Two different facts, two distinguishable values.\n */\nexport function splitPlusTag(address: string): { address: string; tag: string | null } {\n const at = address.indexOf(\"@\");\n if (at < 0 || address.indexOf(\"@\", at + 1) !== -1) return { address, tag: null };\n\n const plus = address.indexOf(\"+\");\n if (plus < 0 || plus > at) return { address, tag: null };\n\n // From the FIRST '+': 'buddy+a+b@' has the tag 'a+b', not 'b'.\n const tag = address.slice(plus + 1, at);\n return { address: address.slice(0, plus) + address.slice(at), tag: tag || null };\n}\n\n/**\n * Does any address in `field` EQUAL any address in `known`?\n *\n * Equality on extracted addresses. Never a substring, never a prefix, never a\n * suffix — `cb@webhouse.dk.evil.com` and `\"cb@webhouse.dk\" <x@evil.dk>` and\n * `buddy@broberg.ai.evil.com` are all rejected, and each of the three was\n * accepted by a live implementation this week.\n *\n * Both sides go through the SAME extraction and the SAME plus-tag\n * normalisation, so `known` may be given as bare addresses or as full fields.\n * An empty or unparseable `field` matches nothing.\n */\nexport function addressesMatch(\n field: string | null | undefined,\n known: string | readonly string[],\n): boolean {\n const present = new Set(extractAddresses(field).map((a) => splitPlusTag(a).address));\n if (present.size === 0) return false;\n\n const list = typeof known === \"string\" ? [known] : known;\n return list.some((k) =>\n extractAddresses(k).some((a) => present.has(splitPlusTag(a).address)),\n );\n}\n\n/**\n * Read an Authentication-Results header (RFC 8601). Reports; does not judge.\n *\n * ANCHORING IS AN ALLOWLIST, NOT A DENYLIST. `\\b` was the original, and a dot is\n * a word boundary, so `\\bdmarc=` matched INSIDE `header.dmarc=pass` — a\n * qualified key read as a verdict:\n *\n * 'header.dmarc=pass header.dkim=pass header.spf=pass; dmarc=fail; dkim=fail; spf=fail'\n * → PASS, while every real verdict in it is `fail`.\n *\n * The first fix was a denylist, `(?<![\\w.-])`. Swept over the whole printable\n * character set it left 27 characters open, including a colon — `arc:dmarc=pass`\n * read as a pass. A denylist has to predict every character a future vendor\n * invents. So: a verdict must stand at the start of the header or after a REAL\n * separator (whitespace, `;` or `,` — RFC 8601's own), and anything else in\n * front of it makes it a qualified key rather than a verdict.\n *\n * Quoted strings and comments are blanked before matching (see\n * maskQuotedAndComments) — they are two more places a verdict cannot stand, and\n * both manufactured verdicts in the prior implementation.\n *\n * ALL occurrences are collected, not the first: a header can carry more than one\n * verdict per method, and \"first wins\" lets an injected `dmarc=pass` drown out\n * the real `dmarc=fail`.\n *\n * PRECEDENCE IS SECURITY-BEARING. An unambiguous fail beats a conflict:\n *\n * 'dkim=pass; dkim=fail; dmarc=fail' → fail, NOT conflicted\n *\n * A method where NO verdict passed is stronger evidence than a disagreement\n * somewhere else. Get the order wrong and a genuine, unambiguous rejection is\n * downgraded to \"cannot determine\" — which a lenient caller then lets through.\n */\nexport function readAuthResults(header: string | null | undefined): AuthResults {\n if (!header || !header.trim()) {\n return {\n verdict: \"no-verdict\",\n conflicted: [],\n reason: \"no Authentication-Results header — the sender is unproven, which is not the same as disproven\",\n };\n }\n\n const { masked, malformed } = maskQuotedAndComments(header);\n if (malformed) {\n return {\n verdict: \"no-verdict\",\n conflicted: [],\n reason:\n \"unterminated quoted string or comment — the header could not be parsed to the end, \" +\n \"and a verdict read out of the readable half would be a verdict an attacker chose the boundary of\",\n };\n }\n\n const found: Partial<Record<AuthMethod, string>> = {};\n const conflicted: AuthMethod[] = [];\n\n for (const method of METHODS) {\n // The optional `/1` is RFC 8601's method-version, and the `\\s*` around it\n // and around `=` is CFWS — comments are already spaces by now. Appendix B.7,\n // the RFC's own \"perfectly legal\" example, is written\n // `dkim (Because I like it) / 1 (One yay) = (wait for it) fail`, and a\n // parser too narrow to read it would report no-verdict on a header that\n // plainly says fail. Rejecting genuine mail is the equally expensive\n // opposite error.\n const re = new RegExp(`(?<=^|[\\\\s;,])${method}\\\\s*(?:/\\\\s*\\\\d+\\\\s*)?=\\\\s*([a-z]+)`, \"gi\");\n const verdicts = [...masked.matchAll(re)].map((m) => m[1]!.toLowerCase());\n if (verdicts.length === 0) continue;\n\n const passed = verdicts.filter((v) => v === \"pass\").length;\n // DISAGREEMENT and UNIFORMLY-NOT-PASSED are two different facts:\n // dmarc=fail a real, unambiguous rejection\n // dkim=pass … dkim=fail a disagreement (RFC B.6 legal, OR injected)\n // Collapsing both into `fail` hides the difference from the caller and puts\n // the policy here, where no consumer can change it. A REPEATED AGREEING\n // verdict is not a conflict — a relay may legitimately repeat itself, and\n // reading that as tampering blocks real mail.\n if (passed > 0 && passed < verdicts.length) conflicted.push(method);\n found[method] = passed === verdicts.length ? \"pass\" : verdicts.find((v) => v !== \"pass\")!;\n }\n\n if (Object.keys(found).length === 0) {\n return {\n verdict: \"no-verdict\",\n conflicted: [],\n reason: \"Authentication-Results was present but carried no readable spf/dkim/dmarc verdict\",\n };\n }\n\n const failed = METHODS.filter(\n (m) => found[m] !== undefined && found[m] !== \"pass\" && !conflicted.includes(m),\n );\n if (failed.length > 0) {\n return {\n verdict: \"fail\",\n ...found,\n conflicted,\n reason: `rejected: ${failed.map((m) => `${m}=${found[m]}`).join(\", \")}`,\n };\n }\n\n if (conflicted.length > 0) {\n return {\n verdict: \"conflicted\",\n ...found,\n conflicted,\n reason:\n `contradictory verdicts for ${conflicted.join(\", \")} — the header carries both a pass and a non-pass. ` +\n \"This is legal multi-tiered authentication (RFC 8601 B.6) OR an injected verdict, and the header cannot tell you which. Your call.\",\n };\n }\n\n // DMARC is the one that binds the From: field to what passed. Without it we\n // know SOMETHING authenticated, but not that the claimed sender is who did.\n if (found.dmarc === undefined) {\n return {\n verdict: \"no-verdict\",\n ...found,\n conflicted,\n reason: \"no dmarc verdict — the From: field is not bound to whatever did pass\",\n };\n }\n\n return { verdict: \"pass\", ...found, conflicted, reason: \"spf/dkim/dmarc pass\" };\n}\n"]}
@@ -0,0 +1,164 @@
1
+ /**
2
+ * @broberg/mail-identity — is this inbound mail provably from who the From:
3
+ * field claims?
4
+ *
5
+ * Four pure functions, no I/O, no dependencies. They answer two questions and
6
+ * refuse a third:
7
+ *
8
+ * WHAT IS AN ADDRESS? extractAddresses · splitPlusTag · addressesMatch
9
+ * WHAT DOES THE HEADER SAY? readAuthResults
10
+ * WHAT DOES A MATCH MEAN? NOT OURS. That is authorisation, and it stays
11
+ * at the call-site where it can be read.
12
+ *
13
+ * The third line is the design. Moving an authorisation decision into a shared
14
+ * dependency would build something worse than the drift it replaces: every
15
+ * consumer inherits a policy it cannot see, and a change to that policy ships
16
+ * silently as a patch release.
17
+ *
18
+ * ORIGIN: buddy and cardmem independently wrote this same rule, and BOTH had
19
+ * holes in it, found the same day in each codebase separately. The value here is
20
+ * not the code — it is the cases in test/, every one of them an attack that
21
+ * WORKED against a live implementation. Each looks correct until someone builds
22
+ * the counter-example.
23
+ */
24
+ /**
25
+ * What the Authentication-Results header says. FOUR outcomes, not three, and
26
+ * not two.
27
+ *
28
+ * pass a dmarc verdict exists and everything present passed
29
+ * fail at least one method's verdicts unambiguously did not pass
30
+ * conflicted one method carries BOTH a passing and a failing verdict
31
+ * no-verdict nothing readable to judge by
32
+ *
33
+ * `conflicted` exists because RFC 8601 Appendix B.6 — "Service Provided,
34
+ * Multi-tiered Authentication Done", the standard's own example of NORMAL
35
+ * OPERATION — is SYNTACTICALLY IDENTICAL to a duplicate-injection attack:
36
+ *
37
+ * dkim=pass reason="good signature" header.i=@mail-router.example.net;
38
+ * dkim=fail reason="bad signature" header.i=@newyork.example.com
39
+ *
40
+ * Two signatures, one good, one bad, in one header. Nothing in the header
41
+ * distinguishes that from an attacker appending `dkim=pass`. So this package
42
+ * does not try: it REPORTS the disagreement and the caller decides. Fail-closed
43
+ * is a perfectly good policy — it is just not ours to choose for everyone.
44
+ *
45
+ * `no-verdict` must NEVER collapse into `pass`, and the inversion is why it
46
+ * matters more than it looks: a genuine SELF-SENT mail has no verdict at all
47
+ * (the header is written by the RECEIVING server, and a mail from your own
48
+ * authenticated session never crosses that boundary), while an external forgery
49
+ * HAS one and it FAILS. A gate demanding `pass` rejects precisely the mail the
50
+ * feature exists for.
51
+ */
52
+ type AuthVerdict = "pass" | "fail" | "conflicted" | "no-verdict";
53
+ /** The three methods that say something about the sending domain. */
54
+ type AuthMethod = "spf" | "dkim" | "dmarc";
55
+ interface AuthResults {
56
+ verdict: AuthVerdict;
57
+ /** Per-method result as read. `pass` only when every verdict for it passed. */
58
+ spf?: string;
59
+ dkim?: string;
60
+ dmarc?: string;
61
+ /** Methods carrying BOTH a passing and a failing verdict. Empty unless `conflicted`. */
62
+ conflicted: readonly AuthMethod[];
63
+ /** Always set, including on `pass` — a caller logging a decision needs the why. */
64
+ reason: string;
65
+ }
66
+ /**
67
+ * Every address in a From/To/Cc field. A DISPLAY NAME NEVER CONTRIBUTES ONE.
68
+ *
69
+ * 'Christian Broberg <cb@webhouse.dk>' → ['cb@webhouse.dk']
70
+ * 'A <a@x.dk>, b@y.dk' → ['a@x.dk', 'b@y.dk']
71
+ * '"cb@webhouse.dk" <attacker@evil.dk>' → ['attacker@evil.dk']
72
+ * '"x <buddy@broberg.ai>, y" <a@evil.dk>' → ['a@evil.dk']
73
+ * '"Broberg, Christian" <cb@webhouse.dk>' → ['cb@webhouse.dk']
74
+ *
75
+ * The first counter-example killed `from.includes(owner)`, which accepted the
76
+ * owner's address written as DISPLAY TEXT — four call-paths reproduced it. The
77
+ * second and third killed `field.split(',')`, which cuts a quoted display name
78
+ * in half before quoting is understood. Both were live; neither was theoretical.
79
+ *
80
+ * Addresses are lowercased. Local parts are formally case-sensitive (RFC 5321),
81
+ * but no provider we send through treats them that way, and NOT normalising
82
+ * would mean `CB@webhouse.dk` fails an owner check that `cb@webhouse.dk` passes
83
+ * — a comparison anyone can sidestep with the shift key.
84
+ */
85
+ declare function extractAddresses(field: string | null | undefined): string[];
86
+ /**
87
+ * Split a plus-tag off an address.
88
+ *
89
+ * 'buddy+whop@broberg.ai' → { address: 'buddy@broberg.ai', tag: 'whop' }
90
+ * 'buddy@broberg.ai' → { address: 'buddy@broberg.ai', tag: null }
91
+ *
92
+ * TWO RULES CARRY THE SECURITY, and they are the reason this is separate from
93
+ * matching rather than folded into it:
94
+ *
95
+ * 1. THE TAG INFORMS, THE ADDRESS PROVES. An unknown tag must still match. A tag
96
+ * able to REFUSE a match would be authorisation disguised as routing, and a
97
+ * typo (`buddy+whopp@`) would make mail vanish silently. `+admin`, `+deploy`
98
+ * and `+urgent` must never mean anything privileged — anyone can type them.
99
+ *
100
+ * 2. SPLIT AFTER extractAddresses(), NEVER on a raw field. Do it the other way
101
+ * round and you are back in the impersonation hole wearing a new hat: a
102
+ * display name must not contribute an address, and that property lives in the
103
+ * extraction.
104
+ *
105
+ * An address with more than one '@' is malformed and is returned UNTOUCHED:
106
+ * `buddy+x@evil.com@broberg.ai` must never be reshaped into something valid.
107
+ *
108
+ * `tag` is `null`, not `undefined`, so that a fixture written as
109
+ * `{ address: 'x', tag: undefined }` cannot pass a deep-equality check against
110
+ * `{ address: 'x' }`. Two different facts, two distinguishable values.
111
+ */
112
+ declare function splitPlusTag(address: string): {
113
+ address: string;
114
+ tag: string | null;
115
+ };
116
+ /**
117
+ * Does any address in `field` EQUAL any address in `known`?
118
+ *
119
+ * Equality on extracted addresses. Never a substring, never a prefix, never a
120
+ * suffix — `cb@webhouse.dk.evil.com` and `"cb@webhouse.dk" <x@evil.dk>` and
121
+ * `buddy@broberg.ai.evil.com` are all rejected, and each of the three was
122
+ * accepted by a live implementation this week.
123
+ *
124
+ * Both sides go through the SAME extraction and the SAME plus-tag
125
+ * normalisation, so `known` may be given as bare addresses or as full fields.
126
+ * An empty or unparseable `field` matches nothing.
127
+ */
128
+ declare function addressesMatch(field: string | null | undefined, known: string | readonly string[]): boolean;
129
+ /**
130
+ * Read an Authentication-Results header (RFC 8601). Reports; does not judge.
131
+ *
132
+ * ANCHORING IS AN ALLOWLIST, NOT A DENYLIST. `\b` was the original, and a dot is
133
+ * a word boundary, so `\bdmarc=` matched INSIDE `header.dmarc=pass` — a
134
+ * qualified key read as a verdict:
135
+ *
136
+ * 'header.dmarc=pass header.dkim=pass header.spf=pass; dmarc=fail; dkim=fail; spf=fail'
137
+ * → PASS, while every real verdict in it is `fail`.
138
+ *
139
+ * The first fix was a denylist, `(?<![\w.-])`. Swept over the whole printable
140
+ * character set it left 27 characters open, including a colon — `arc:dmarc=pass`
141
+ * read as a pass. A denylist has to predict every character a future vendor
142
+ * invents. So: a verdict must stand at the start of the header or after a REAL
143
+ * separator (whitespace, `;` or `,` — RFC 8601's own), and anything else in
144
+ * front of it makes it a qualified key rather than a verdict.
145
+ *
146
+ * Quoted strings and comments are blanked before matching (see
147
+ * maskQuotedAndComments) — they are two more places a verdict cannot stand, and
148
+ * both manufactured verdicts in the prior implementation.
149
+ *
150
+ * ALL occurrences are collected, not the first: a header can carry more than one
151
+ * verdict per method, and "first wins" lets an injected `dmarc=pass` drown out
152
+ * the real `dmarc=fail`.
153
+ *
154
+ * PRECEDENCE IS SECURITY-BEARING. An unambiguous fail beats a conflict:
155
+ *
156
+ * 'dkim=pass; dkim=fail; dmarc=fail' → fail, NOT conflicted
157
+ *
158
+ * A method where NO verdict passed is stronger evidence than a disagreement
159
+ * somewhere else. Get the order wrong and a genuine, unambiguous rejection is
160
+ * downgraded to "cannot determine" — which a lenient caller then lets through.
161
+ */
162
+ declare function readAuthResults(header: string | null | undefined): AuthResults;
163
+
164
+ export { type AuthMethod, type AuthResults, type AuthVerdict, addressesMatch, extractAddresses, readAuthResults, splitPlusTag };
@@ -0,0 +1,164 @@
1
+ /**
2
+ * @broberg/mail-identity — is this inbound mail provably from who the From:
3
+ * field claims?
4
+ *
5
+ * Four pure functions, no I/O, no dependencies. They answer two questions and
6
+ * refuse a third:
7
+ *
8
+ * WHAT IS AN ADDRESS? extractAddresses · splitPlusTag · addressesMatch
9
+ * WHAT DOES THE HEADER SAY? readAuthResults
10
+ * WHAT DOES A MATCH MEAN? NOT OURS. That is authorisation, and it stays
11
+ * at the call-site where it can be read.
12
+ *
13
+ * The third line is the design. Moving an authorisation decision into a shared
14
+ * dependency would build something worse than the drift it replaces: every
15
+ * consumer inherits a policy it cannot see, and a change to that policy ships
16
+ * silently as a patch release.
17
+ *
18
+ * ORIGIN: buddy and cardmem independently wrote this same rule, and BOTH had
19
+ * holes in it, found the same day in each codebase separately. The value here is
20
+ * not the code — it is the cases in test/, every one of them an attack that
21
+ * WORKED against a live implementation. Each looks correct until someone builds
22
+ * the counter-example.
23
+ */
24
+ /**
25
+ * What the Authentication-Results header says. FOUR outcomes, not three, and
26
+ * not two.
27
+ *
28
+ * pass a dmarc verdict exists and everything present passed
29
+ * fail at least one method's verdicts unambiguously did not pass
30
+ * conflicted one method carries BOTH a passing and a failing verdict
31
+ * no-verdict nothing readable to judge by
32
+ *
33
+ * `conflicted` exists because RFC 8601 Appendix B.6 — "Service Provided,
34
+ * Multi-tiered Authentication Done", the standard's own example of NORMAL
35
+ * OPERATION — is SYNTACTICALLY IDENTICAL to a duplicate-injection attack:
36
+ *
37
+ * dkim=pass reason="good signature" header.i=@mail-router.example.net;
38
+ * dkim=fail reason="bad signature" header.i=@newyork.example.com
39
+ *
40
+ * Two signatures, one good, one bad, in one header. Nothing in the header
41
+ * distinguishes that from an attacker appending `dkim=pass`. So this package
42
+ * does not try: it REPORTS the disagreement and the caller decides. Fail-closed
43
+ * is a perfectly good policy — it is just not ours to choose for everyone.
44
+ *
45
+ * `no-verdict` must NEVER collapse into `pass`, and the inversion is why it
46
+ * matters more than it looks: a genuine SELF-SENT mail has no verdict at all
47
+ * (the header is written by the RECEIVING server, and a mail from your own
48
+ * authenticated session never crosses that boundary), while an external forgery
49
+ * HAS one and it FAILS. A gate demanding `pass` rejects precisely the mail the
50
+ * feature exists for.
51
+ */
52
+ type AuthVerdict = "pass" | "fail" | "conflicted" | "no-verdict";
53
+ /** The three methods that say something about the sending domain. */
54
+ type AuthMethod = "spf" | "dkim" | "dmarc";
55
+ interface AuthResults {
56
+ verdict: AuthVerdict;
57
+ /** Per-method result as read. `pass` only when every verdict for it passed. */
58
+ spf?: string;
59
+ dkim?: string;
60
+ dmarc?: string;
61
+ /** Methods carrying BOTH a passing and a failing verdict. Empty unless `conflicted`. */
62
+ conflicted: readonly AuthMethod[];
63
+ /** Always set, including on `pass` — a caller logging a decision needs the why. */
64
+ reason: string;
65
+ }
66
+ /**
67
+ * Every address in a From/To/Cc field. A DISPLAY NAME NEVER CONTRIBUTES ONE.
68
+ *
69
+ * 'Christian Broberg <cb@webhouse.dk>' → ['cb@webhouse.dk']
70
+ * 'A <a@x.dk>, b@y.dk' → ['a@x.dk', 'b@y.dk']
71
+ * '"cb@webhouse.dk" <attacker@evil.dk>' → ['attacker@evil.dk']
72
+ * '"x <buddy@broberg.ai>, y" <a@evil.dk>' → ['a@evil.dk']
73
+ * '"Broberg, Christian" <cb@webhouse.dk>' → ['cb@webhouse.dk']
74
+ *
75
+ * The first counter-example killed `from.includes(owner)`, which accepted the
76
+ * owner's address written as DISPLAY TEXT — four call-paths reproduced it. The
77
+ * second and third killed `field.split(',')`, which cuts a quoted display name
78
+ * in half before quoting is understood. Both were live; neither was theoretical.
79
+ *
80
+ * Addresses are lowercased. Local parts are formally case-sensitive (RFC 5321),
81
+ * but no provider we send through treats them that way, and NOT normalising
82
+ * would mean `CB@webhouse.dk` fails an owner check that `cb@webhouse.dk` passes
83
+ * — a comparison anyone can sidestep with the shift key.
84
+ */
85
+ declare function extractAddresses(field: string | null | undefined): string[];
86
+ /**
87
+ * Split a plus-tag off an address.
88
+ *
89
+ * 'buddy+whop@broberg.ai' → { address: 'buddy@broberg.ai', tag: 'whop' }
90
+ * 'buddy@broberg.ai' → { address: 'buddy@broberg.ai', tag: null }
91
+ *
92
+ * TWO RULES CARRY THE SECURITY, and they are the reason this is separate from
93
+ * matching rather than folded into it:
94
+ *
95
+ * 1. THE TAG INFORMS, THE ADDRESS PROVES. An unknown tag must still match. A tag
96
+ * able to REFUSE a match would be authorisation disguised as routing, and a
97
+ * typo (`buddy+whopp@`) would make mail vanish silently. `+admin`, `+deploy`
98
+ * and `+urgent` must never mean anything privileged — anyone can type them.
99
+ *
100
+ * 2. SPLIT AFTER extractAddresses(), NEVER on a raw field. Do it the other way
101
+ * round and you are back in the impersonation hole wearing a new hat: a
102
+ * display name must not contribute an address, and that property lives in the
103
+ * extraction.
104
+ *
105
+ * An address with more than one '@' is malformed and is returned UNTOUCHED:
106
+ * `buddy+x@evil.com@broberg.ai` must never be reshaped into something valid.
107
+ *
108
+ * `tag` is `null`, not `undefined`, so that a fixture written as
109
+ * `{ address: 'x', tag: undefined }` cannot pass a deep-equality check against
110
+ * `{ address: 'x' }`. Two different facts, two distinguishable values.
111
+ */
112
+ declare function splitPlusTag(address: string): {
113
+ address: string;
114
+ tag: string | null;
115
+ };
116
+ /**
117
+ * Does any address in `field` EQUAL any address in `known`?
118
+ *
119
+ * Equality on extracted addresses. Never a substring, never a prefix, never a
120
+ * suffix — `cb@webhouse.dk.evil.com` and `"cb@webhouse.dk" <x@evil.dk>` and
121
+ * `buddy@broberg.ai.evil.com` are all rejected, and each of the three was
122
+ * accepted by a live implementation this week.
123
+ *
124
+ * Both sides go through the SAME extraction and the SAME plus-tag
125
+ * normalisation, so `known` may be given as bare addresses or as full fields.
126
+ * An empty or unparseable `field` matches nothing.
127
+ */
128
+ declare function addressesMatch(field: string | null | undefined, known: string | readonly string[]): boolean;
129
+ /**
130
+ * Read an Authentication-Results header (RFC 8601). Reports; does not judge.
131
+ *
132
+ * ANCHORING IS AN ALLOWLIST, NOT A DENYLIST. `\b` was the original, and a dot is
133
+ * a word boundary, so `\bdmarc=` matched INSIDE `header.dmarc=pass` — a
134
+ * qualified key read as a verdict:
135
+ *
136
+ * 'header.dmarc=pass header.dkim=pass header.spf=pass; dmarc=fail; dkim=fail; spf=fail'
137
+ * → PASS, while every real verdict in it is `fail`.
138
+ *
139
+ * The first fix was a denylist, `(?<![\w.-])`. Swept over the whole printable
140
+ * character set it left 27 characters open, including a colon — `arc:dmarc=pass`
141
+ * read as a pass. A denylist has to predict every character a future vendor
142
+ * invents. So: a verdict must stand at the start of the header or after a REAL
143
+ * separator (whitespace, `;` or `,` — RFC 8601's own), and anything else in
144
+ * front of it makes it a qualified key rather than a verdict.
145
+ *
146
+ * Quoted strings and comments are blanked before matching (see
147
+ * maskQuotedAndComments) — they are two more places a verdict cannot stand, and
148
+ * both manufactured verdicts in the prior implementation.
149
+ *
150
+ * ALL occurrences are collected, not the first: a header can carry more than one
151
+ * verdict per method, and "first wins" lets an injected `dmarc=pass` drown out
152
+ * the real `dmarc=fail`.
153
+ *
154
+ * PRECEDENCE IS SECURITY-BEARING. An unambiguous fail beats a conflict:
155
+ *
156
+ * 'dkim=pass; dkim=fail; dmarc=fail' → fail, NOT conflicted
157
+ *
158
+ * A method where NO verdict passed is stronger evidence than a disagreement
159
+ * somewhere else. Get the order wrong and a genuine, unambiguous rejection is
160
+ * downgraded to "cannot determine" — which a lenient caller then lets through.
161
+ */
162
+ declare function readAuthResults(header: string | null | undefined): AuthResults;
163
+
164
+ export { type AuthMethod, type AuthResults, type AuthVerdict, addressesMatch, extractAddresses, readAuthResults, splitPlusTag };
package/dist/index.js ADDED
@@ -0,0 +1,165 @@
1
+ // src/index.ts
2
+ var METHODS = ["spf", "dkim", "dmarc"];
3
+ function maskQuotedAndComments(s) {
4
+ const out = s.split("");
5
+ let quoted = false;
6
+ let comment = 0;
7
+ for (let i = 0; i < s.length; i++) {
8
+ const c = s[i];
9
+ if ((quoted || comment > 0) && c === "\\" && i + 1 < s.length) {
10
+ out[i] = " ";
11
+ out[i + 1] = " ";
12
+ i++;
13
+ continue;
14
+ }
15
+ if (quoted) {
16
+ out[i] = " ";
17
+ if (c === '"') quoted = false;
18
+ continue;
19
+ }
20
+ if (comment > 0) {
21
+ if (c === "(") comment++;
22
+ else if (c === ")") comment--;
23
+ out[i] = " ";
24
+ continue;
25
+ }
26
+ if (c === '"') {
27
+ quoted = true;
28
+ out[i] = " ";
29
+ continue;
30
+ }
31
+ if (c === "(") {
32
+ comment++;
33
+ out[i] = " ";
34
+ continue;
35
+ }
36
+ }
37
+ return { masked: out.join(""), malformed: quoted || comment > 0 };
38
+ }
39
+ function normalise(s) {
40
+ const t = s.trim().toLowerCase();
41
+ return t.length > 0 ? t : null;
42
+ }
43
+ function addressOfPart(raw, masked, from, to) {
44
+ let open = -1;
45
+ let close = -1;
46
+ let depth = 0;
47
+ for (let i = from; i < to; i++) {
48
+ const c = masked[i];
49
+ if (c === "<") {
50
+ if (depth === 0) open = i;
51
+ depth++;
52
+ } else if (c === ">" && depth > 0) {
53
+ depth--;
54
+ if (depth === 0) {
55
+ close = i;
56
+ break;
57
+ }
58
+ }
59
+ }
60
+ if (open >= 0 && close > open) return normalise(raw.slice(open + 1, close));
61
+ if (open >= 0) return normalise(raw.slice(from, to));
62
+ return normalise(masked.slice(from, to));
63
+ }
64
+ function extractAddresses(field) {
65
+ if (!field) return [];
66
+ const { masked } = maskQuotedAndComments(field);
67
+ const out = [];
68
+ let start = 0;
69
+ let angle = 0;
70
+ const flush = (end) => {
71
+ const a = addressOfPart(field, masked, start, end);
72
+ if (a && a.includes("@")) out.push(a);
73
+ start = end + 1;
74
+ };
75
+ for (let i = 0; i < masked.length; i++) {
76
+ const c = masked[i];
77
+ if (c === "<") angle++;
78
+ else if (c === ">" && angle > 0) angle--;
79
+ else if (c === "," && angle === 0) flush(i);
80
+ }
81
+ flush(masked.length);
82
+ return out;
83
+ }
84
+ function splitPlusTag(address) {
85
+ const at = address.indexOf("@");
86
+ if (at < 0 || address.indexOf("@", at + 1) !== -1) return { address, tag: null };
87
+ const plus = address.indexOf("+");
88
+ if (plus < 0 || plus > at) return { address, tag: null };
89
+ const tag = address.slice(plus + 1, at);
90
+ return { address: address.slice(0, plus) + address.slice(at), tag: tag || null };
91
+ }
92
+ function addressesMatch(field, known) {
93
+ const present = new Set(extractAddresses(field).map((a) => splitPlusTag(a).address));
94
+ if (present.size === 0) return false;
95
+ const list = typeof known === "string" ? [known] : known;
96
+ return list.some(
97
+ (k) => extractAddresses(k).some((a) => present.has(splitPlusTag(a).address))
98
+ );
99
+ }
100
+ function readAuthResults(header) {
101
+ if (!header || !header.trim()) {
102
+ return {
103
+ verdict: "no-verdict",
104
+ conflicted: [],
105
+ reason: "no Authentication-Results header \u2014 the sender is unproven, which is not the same as disproven"
106
+ };
107
+ }
108
+ const { masked, malformed } = maskQuotedAndComments(header);
109
+ if (malformed) {
110
+ return {
111
+ verdict: "no-verdict",
112
+ conflicted: [],
113
+ reason: "unterminated quoted string or comment \u2014 the header could not be parsed to the end, and a verdict read out of the readable half would be a verdict an attacker chose the boundary of"
114
+ };
115
+ }
116
+ const found = {};
117
+ const conflicted = [];
118
+ for (const method of METHODS) {
119
+ const re = new RegExp(`(?<=^|[\\s;,])${method}\\s*(?:/\\s*\\d+\\s*)?=\\s*([a-z]+)`, "gi");
120
+ const verdicts = [...masked.matchAll(re)].map((m) => m[1].toLowerCase());
121
+ if (verdicts.length === 0) continue;
122
+ const passed = verdicts.filter((v) => v === "pass").length;
123
+ if (passed > 0 && passed < verdicts.length) conflicted.push(method);
124
+ found[method] = passed === verdicts.length ? "pass" : verdicts.find((v) => v !== "pass");
125
+ }
126
+ if (Object.keys(found).length === 0) {
127
+ return {
128
+ verdict: "no-verdict",
129
+ conflicted: [],
130
+ reason: "Authentication-Results was present but carried no readable spf/dkim/dmarc verdict"
131
+ };
132
+ }
133
+ const failed = METHODS.filter(
134
+ (m) => found[m] !== void 0 && found[m] !== "pass" && !conflicted.includes(m)
135
+ );
136
+ if (failed.length > 0) {
137
+ return {
138
+ verdict: "fail",
139
+ ...found,
140
+ conflicted,
141
+ reason: `rejected: ${failed.map((m) => `${m}=${found[m]}`).join(", ")}`
142
+ };
143
+ }
144
+ if (conflicted.length > 0) {
145
+ return {
146
+ verdict: "conflicted",
147
+ ...found,
148
+ conflicted,
149
+ reason: `contradictory verdicts for ${conflicted.join(", ")} \u2014 the header carries both a pass and a non-pass. This is legal multi-tiered authentication (RFC 8601 B.6) OR an injected verdict, and the header cannot tell you which. Your call.`
150
+ };
151
+ }
152
+ if (found.dmarc === void 0) {
153
+ return {
154
+ verdict: "no-verdict",
155
+ ...found,
156
+ conflicted,
157
+ reason: "no dmarc verdict \u2014 the From: field is not bound to whatever did pass"
158
+ };
159
+ }
160
+ return { verdict: "pass", ...found, conflicted, reason: "spf/dkim/dmarc pass" };
161
+ }
162
+
163
+ export { addressesMatch, extractAddresses, readAuthResults, splitPlusTag };
164
+ //# sourceMappingURL=index.js.map
165
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @broberg/mail-identity — is this inbound mail provably from who the From:\n * field claims?\n *\n * Four pure functions, no I/O, no dependencies. They answer two questions and\n * refuse a third:\n *\n * WHAT IS AN ADDRESS? extractAddresses · splitPlusTag · addressesMatch\n * WHAT DOES THE HEADER SAY? readAuthResults\n * WHAT DOES A MATCH MEAN? NOT OURS. That is authorisation, and it stays\n * at the call-site where it can be read.\n *\n * The third line is the design. Moving an authorisation decision into a shared\n * dependency would build something worse than the drift it replaces: every\n * consumer inherits a policy it cannot see, and a change to that policy ships\n * silently as a patch release.\n *\n * ORIGIN: buddy and cardmem independently wrote this same rule, and BOTH had\n * holes in it, found the same day in each codebase separately. The value here is\n * not the code — it is the cases in test/, every one of them an attack that\n * WORKED against a live implementation. Each looks correct until someone builds\n * the counter-example.\n */\n\n/**\n * What the Authentication-Results header says. FOUR outcomes, not three, and\n * not two.\n *\n * pass a dmarc verdict exists and everything present passed\n * fail at least one method's verdicts unambiguously did not pass\n * conflicted one method carries BOTH a passing and a failing verdict\n * no-verdict nothing readable to judge by\n *\n * `conflicted` exists because RFC 8601 Appendix B.6 — \"Service Provided,\n * Multi-tiered Authentication Done\", the standard's own example of NORMAL\n * OPERATION — is SYNTACTICALLY IDENTICAL to a duplicate-injection attack:\n *\n * dkim=pass reason=\"good signature\" header.i=@mail-router.example.net;\n * dkim=fail reason=\"bad signature\" header.i=@newyork.example.com\n *\n * Two signatures, one good, one bad, in one header. Nothing in the header\n * distinguishes that from an attacker appending `dkim=pass`. So this package\n * does not try: it REPORTS the disagreement and the caller decides. Fail-closed\n * is a perfectly good policy — it is just not ours to choose for everyone.\n *\n * `no-verdict` must NEVER collapse into `pass`, and the inversion is why it\n * matters more than it looks: a genuine SELF-SENT mail has no verdict at all\n * (the header is written by the RECEIVING server, and a mail from your own\n * authenticated session never crosses that boundary), while an external forgery\n * HAS one and it FAILS. A gate demanding `pass` rejects precisely the mail the\n * feature exists for.\n */\nexport type AuthVerdict = \"pass\" | \"fail\" | \"conflicted\" | \"no-verdict\";\n\n/** The three methods that say something about the sending domain. */\nexport type AuthMethod = \"spf\" | \"dkim\" | \"dmarc\";\n\nconst METHODS: readonly AuthMethod[] = [\"spf\", \"dkim\", \"dmarc\"];\n\nexport interface AuthResults {\n verdict: AuthVerdict;\n /** Per-method result as read. `pass` only when every verdict for it passed. */\n spf?: string;\n dkim?: string;\n dmarc?: string;\n /** Methods carrying BOTH a passing and a failing verdict. Empty unless `conflicted`. */\n conflicted: readonly AuthMethod[];\n /** Always set, including on `pass` — a caller logging a decision needs the why. */\n reason: string;\n}\n\n/**\n * A view of `s` in which every quoted-string and every (nested) comment — and\n * their delimiters — is replaced by a SPACE, position for position.\n *\n * THIS IS THE WHOLE TRICK, and it is what a regex over the raw string cannot do.\n * Both grammars we parse (RFC 5322 address lists, RFC 8601 auth results) have\n * quoting, and in both a comment is defined to be whitespace. A regex has\n * neither notion, so it sees a legal separator wherever there is a space —\n * INCLUDING the spaces inside a quoted string. Measured against the prior\n * implementation, all three of these came from exactly that:\n *\n * 'Authentication-Results: …; dkim=pass reason=\"relayed dmarc=pass ok\"'\n * → no-verdict became PASS. Text inside a reason string became a verdict.\n *\n * 'Authentication-Results: …; dkim=pass (relayed dmarc=pass ok)'\n * → same, via a comment.\n *\n * 'To: \"x <buddy@broberg.ai>, y\" <attacker@evil.dk>'\n * → split-on-comma cut the quoted display name in half, and the half\n * carried an <addr> that was only ever decoration. A display name\n * contributed an address, which is the single property this package\n * exists to guarantee it cannot.\n *\n * Replacing with a space rather than DELETING is deliberate: deletion joins the\n * neighbours, so `dmar(x)c=pass` would become the verdict `dmarc=pass` out of\n * text that is not one. A comment is whitespace; blanking says exactly that.\n *\n * Unterminated quotes and comments blank the remainder of the string, and are\n * reported as `malformed` so a caller of this helper can refuse to read a\n * PARTIAL result out of a header it could not finish parsing. That distinction\n * is not pedantry — the qualifier values in an auth header (`smtp.mailfrom=`,\n * `header.i=`) come from the message, so an unterminated delimiter there would\n * blank every verdict AFTER it while leaving the earlier ones standing. Erasing\n * a trailing `dmarc=fail` and keeping a leading `spf=pass` is a strictly useful\n * outcome for an attacker, so a header that does not parse yields nothing.\n */\nfunction maskQuotedAndComments(s: string): { masked: string; malformed: boolean } {\n const out = s.split(\"\");\n let quoted = false;\n let comment = 0;\n\n for (let i = 0; i < s.length; i++) {\n const c = s[i]!;\n\n if ((quoted || comment > 0) && c === \"\\\\\" && i + 1 < s.length) {\n out[i] = \" \";\n out[i + 1] = \" \";\n i++;\n continue;\n }\n if (quoted) {\n out[i] = \" \";\n if (c === '\"') quoted = false;\n continue;\n }\n if (comment > 0) {\n if (c === \"(\") comment++;\n else if (c === \")\") comment--;\n out[i] = \" \";\n continue;\n }\n if (c === '\"') {\n quoted = true;\n out[i] = \" \";\n continue;\n }\n if (c === \"(\") {\n comment++;\n out[i] = \" \";\n continue;\n }\n }\n\n return { masked: out.join(\"\"), malformed: quoted || comment > 0 };\n}\n\n/** Lowercase + trim, or null when nothing is left. */\nfunction normalise(s: string): string | null {\n const t = s.trim().toLowerCase();\n return t.length > 0 ? t : null;\n}\n\n/**\n * The address of ONE address-list entry, given the raw field and its mask.\n *\n * Angle brackets are located in the MASKED view, so a `<` inside a quoted\n * display name is simply not there to be found — but the address itself is\n * sliced from the RAW string, so a quoted local part (`<\"odd name\"@example.com>`)\n * survives intact.\n */\nfunction addressOfPart(raw: string, masked: string, from: number, to: number): string | null {\n let open = -1;\n let close = -1;\n let depth = 0;\n\n for (let i = from; i < to; i++) {\n const c = masked[i]!;\n if (c === \"<\") {\n if (depth === 0) open = i;\n depth++;\n } else if (c === \">\" && depth > 0) {\n depth--;\n if (depth === 0) {\n close = i;\n break;\n }\n }\n }\n\n if (open >= 0 && close > open) return normalise(raw.slice(open + 1, close));\n\n // An UNTERMINATED '<' is malformed. Hand back the part as it stands rather\n // than guessing which half was meant to be the address — something that\n // matches nothing beats something that might match the owner.\n if (open >= 0) return normalise(raw.slice(from, to));\n\n // No angle brackets: the address is the bare text, which is the MASKED text —\n // so a comment such as `cb@webhouse.dk (Christian)` contributes nothing.\n return normalise(masked.slice(from, to));\n}\n\n/**\n * Every address in a From/To/Cc field. A DISPLAY NAME NEVER CONTRIBUTES ONE.\n *\n * 'Christian Broberg <cb@webhouse.dk>' → ['cb@webhouse.dk']\n * 'A <a@x.dk>, b@y.dk' → ['a@x.dk', 'b@y.dk']\n * '\"cb@webhouse.dk\" <attacker@evil.dk>' → ['attacker@evil.dk']\n * '\"x <buddy@broberg.ai>, y\" <a@evil.dk>' → ['a@evil.dk']\n * '\"Broberg, Christian\" <cb@webhouse.dk>' → ['cb@webhouse.dk']\n *\n * The first counter-example killed `from.includes(owner)`, which accepted the\n * owner's address written as DISPLAY TEXT — four call-paths reproduced it. The\n * second and third killed `field.split(',')`, which cuts a quoted display name\n * in half before quoting is understood. Both were live; neither was theoretical.\n *\n * Addresses are lowercased. Local parts are formally case-sensitive (RFC 5321),\n * but no provider we send through treats them that way, and NOT normalising\n * would mean `CB@webhouse.dk` fails an owner check that `cb@webhouse.dk` passes\n * — a comparison anyone can sidestep with the shift key.\n */\nexport function extractAddresses(field: string | null | undefined): string[] {\n if (!field) return [];\n // A malformed field is not rejected outright here: blanking already makes the\n // unparseable remainder match nothing, and an address list has no equivalent\n // of the partial-verdict problem — an address either equals a known one or it\n // does not.\n const { masked } = maskQuotedAndComments(field);\n\n const out: string[] = [];\n let start = 0;\n let angle = 0;\n\n const flush = (end: number): void => {\n const a = addressOfPart(field, masked, start, end);\n // 0.1.1 — a part with no '@' is not an address (buddy, 31/10: `No Address\n // Here` came back as ['no address here']). Text is not promoted to an\n // address because the field happened to contain no brackets.\n if (a && a.includes(\"@\")) out.push(a);\n start = end + 1;\n };\n\n for (let i = 0; i < masked.length; i++) {\n const c = masked[i]!;\n if (c === \"<\") angle++;\n else if (c === \">\" && angle > 0) angle--;\n else if (c === \",\" && angle === 0) flush(i);\n }\n flush(masked.length);\n\n return out;\n}\n\n/**\n * Split a plus-tag off an address.\n *\n * 'buddy+whop@broberg.ai' → { address: 'buddy@broberg.ai', tag: 'whop' }\n * 'buddy@broberg.ai' → { address: 'buddy@broberg.ai', tag: null }\n *\n * TWO RULES CARRY THE SECURITY, and they are the reason this is separate from\n * matching rather than folded into it:\n *\n * 1. THE TAG INFORMS, THE ADDRESS PROVES. An unknown tag must still match. A tag\n * able to REFUSE a match would be authorisation disguised as routing, and a\n * typo (`buddy+whopp@`) would make mail vanish silently. `+admin`, `+deploy`\n * and `+urgent` must never mean anything privileged — anyone can type them.\n *\n * 2. SPLIT AFTER extractAddresses(), NEVER on a raw field. Do it the other way\n * round and you are back in the impersonation hole wearing a new hat: a\n * display name must not contribute an address, and that property lives in the\n * extraction.\n *\n * An address with more than one '@' is malformed and is returned UNTOUCHED:\n * `buddy+x@evil.com@broberg.ai` must never be reshaped into something valid.\n *\n * `tag` is `null`, not `undefined`, so that a fixture written as\n * `{ address: 'x', tag: undefined }` cannot pass a deep-equality check against\n * `{ address: 'x' }`. Two different facts, two distinguishable values.\n */\nexport function splitPlusTag(address: string): { address: string; tag: string | null } {\n const at = address.indexOf(\"@\");\n if (at < 0 || address.indexOf(\"@\", at + 1) !== -1) return { address, tag: null };\n\n const plus = address.indexOf(\"+\");\n if (plus < 0 || plus > at) return { address, tag: null };\n\n // From the FIRST '+': 'buddy+a+b@' has the tag 'a+b', not 'b'.\n const tag = address.slice(plus + 1, at);\n return { address: address.slice(0, plus) + address.slice(at), tag: tag || null };\n}\n\n/**\n * Does any address in `field` EQUAL any address in `known`?\n *\n * Equality on extracted addresses. Never a substring, never a prefix, never a\n * suffix — `cb@webhouse.dk.evil.com` and `\"cb@webhouse.dk\" <x@evil.dk>` and\n * `buddy@broberg.ai.evil.com` are all rejected, and each of the three was\n * accepted by a live implementation this week.\n *\n * Both sides go through the SAME extraction and the SAME plus-tag\n * normalisation, so `known` may be given as bare addresses or as full fields.\n * An empty or unparseable `field` matches nothing.\n */\nexport function addressesMatch(\n field: string | null | undefined,\n known: string | readonly string[],\n): boolean {\n const present = new Set(extractAddresses(field).map((a) => splitPlusTag(a).address));\n if (present.size === 0) return false;\n\n const list = typeof known === \"string\" ? [known] : known;\n return list.some((k) =>\n extractAddresses(k).some((a) => present.has(splitPlusTag(a).address)),\n );\n}\n\n/**\n * Read an Authentication-Results header (RFC 8601). Reports; does not judge.\n *\n * ANCHORING IS AN ALLOWLIST, NOT A DENYLIST. `\\b` was the original, and a dot is\n * a word boundary, so `\\bdmarc=` matched INSIDE `header.dmarc=pass` — a\n * qualified key read as a verdict:\n *\n * 'header.dmarc=pass header.dkim=pass header.spf=pass; dmarc=fail; dkim=fail; spf=fail'\n * → PASS, while every real verdict in it is `fail`.\n *\n * The first fix was a denylist, `(?<![\\w.-])`. Swept over the whole printable\n * character set it left 27 characters open, including a colon — `arc:dmarc=pass`\n * read as a pass. A denylist has to predict every character a future vendor\n * invents. So: a verdict must stand at the start of the header or after a REAL\n * separator (whitespace, `;` or `,` — RFC 8601's own), and anything else in\n * front of it makes it a qualified key rather than a verdict.\n *\n * Quoted strings and comments are blanked before matching (see\n * maskQuotedAndComments) — they are two more places a verdict cannot stand, and\n * both manufactured verdicts in the prior implementation.\n *\n * ALL occurrences are collected, not the first: a header can carry more than one\n * verdict per method, and \"first wins\" lets an injected `dmarc=pass` drown out\n * the real `dmarc=fail`.\n *\n * PRECEDENCE IS SECURITY-BEARING. An unambiguous fail beats a conflict:\n *\n * 'dkim=pass; dkim=fail; dmarc=fail' → fail, NOT conflicted\n *\n * A method where NO verdict passed is stronger evidence than a disagreement\n * somewhere else. Get the order wrong and a genuine, unambiguous rejection is\n * downgraded to \"cannot determine\" — which a lenient caller then lets through.\n */\nexport function readAuthResults(header: string | null | undefined): AuthResults {\n if (!header || !header.trim()) {\n return {\n verdict: \"no-verdict\",\n conflicted: [],\n reason: \"no Authentication-Results header — the sender is unproven, which is not the same as disproven\",\n };\n }\n\n const { masked, malformed } = maskQuotedAndComments(header);\n if (malformed) {\n return {\n verdict: \"no-verdict\",\n conflicted: [],\n reason:\n \"unterminated quoted string or comment — the header could not be parsed to the end, \" +\n \"and a verdict read out of the readable half would be a verdict an attacker chose the boundary of\",\n };\n }\n\n const found: Partial<Record<AuthMethod, string>> = {};\n const conflicted: AuthMethod[] = [];\n\n for (const method of METHODS) {\n // The optional `/1` is RFC 8601's method-version, and the `\\s*` around it\n // and around `=` is CFWS — comments are already spaces by now. Appendix B.7,\n // the RFC's own \"perfectly legal\" example, is written\n // `dkim (Because I like it) / 1 (One yay) = (wait for it) fail`, and a\n // parser too narrow to read it would report no-verdict on a header that\n // plainly says fail. Rejecting genuine mail is the equally expensive\n // opposite error.\n const re = new RegExp(`(?<=^|[\\\\s;,])${method}\\\\s*(?:/\\\\s*\\\\d+\\\\s*)?=\\\\s*([a-z]+)`, \"gi\");\n const verdicts = [...masked.matchAll(re)].map((m) => m[1]!.toLowerCase());\n if (verdicts.length === 0) continue;\n\n const passed = verdicts.filter((v) => v === \"pass\").length;\n // DISAGREEMENT and UNIFORMLY-NOT-PASSED are two different facts:\n // dmarc=fail a real, unambiguous rejection\n // dkim=pass … dkim=fail a disagreement (RFC B.6 legal, OR injected)\n // Collapsing both into `fail` hides the difference from the caller and puts\n // the policy here, where no consumer can change it. A REPEATED AGREEING\n // verdict is not a conflict — a relay may legitimately repeat itself, and\n // reading that as tampering blocks real mail.\n if (passed > 0 && passed < verdicts.length) conflicted.push(method);\n found[method] = passed === verdicts.length ? \"pass\" : verdicts.find((v) => v !== \"pass\")!;\n }\n\n if (Object.keys(found).length === 0) {\n return {\n verdict: \"no-verdict\",\n conflicted: [],\n reason: \"Authentication-Results was present but carried no readable spf/dkim/dmarc verdict\",\n };\n }\n\n const failed = METHODS.filter(\n (m) => found[m] !== undefined && found[m] !== \"pass\" && !conflicted.includes(m),\n );\n if (failed.length > 0) {\n return {\n verdict: \"fail\",\n ...found,\n conflicted,\n reason: `rejected: ${failed.map((m) => `${m}=${found[m]}`).join(\", \")}`,\n };\n }\n\n if (conflicted.length > 0) {\n return {\n verdict: \"conflicted\",\n ...found,\n conflicted,\n reason:\n `contradictory verdicts for ${conflicted.join(\", \")} — the header carries both a pass and a non-pass. ` +\n \"This is legal multi-tiered authentication (RFC 8601 B.6) OR an injected verdict, and the header cannot tell you which. Your call.\",\n };\n }\n\n // DMARC is the one that binds the From: field to what passed. Without it we\n // know SOMETHING authenticated, but not that the claimed sender is who did.\n if (found.dmarc === undefined) {\n return {\n verdict: \"no-verdict\",\n ...found,\n conflicted,\n reason: \"no dmarc verdict — the From: field is not bound to whatever did pass\",\n };\n }\n\n return { verdict: \"pass\", ...found, conflicted, reason: \"spf/dkim/dmarc pass\" };\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "@broberg/mail-identity",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Is this inbound mail provably from who the From: field claims? Reads addresses and Authentication-Results the way the grammar does, not the way a regex does. Four pure functions, zero dependencies, no I/O. It owns WHAT AN ADDRESS IS and WHAT THE AUTH HEADER SAYS — never what a match MEANS.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "sideEffects": false,
8
+ "files": [
9
+ "dist",
10
+ "README.md"
11
+ ],
12
+ "main": "./dist/index.cjs",
13
+ "module": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "import": "./dist/index.js",
19
+ "require": "./dist/index.cjs"
20
+ },
21
+ "./package.json": "./package.json"
22
+ },
23
+ "scripts": {
24
+ "build": "tsup",
25
+ "test": "vitest run --reporter=default --reporter=json --outputFile.json=.vitest-report/results.json && node ../../scripts/mutations-if-changed.mjs test/mutations.mjs",
26
+ "typecheck": "tsc --noEmit"
27
+ },
28
+ "devDependencies": {
29
+ "tsup": "^8.3.0",
30
+ "typescript": "^5.6.0",
31
+ "vitest": "^2.1.0"
32
+ },
33
+ "keywords": [
34
+ "email",
35
+ "mail",
36
+ "inbound",
37
+ "sender",
38
+ "identity",
39
+ "spoofing",
40
+ "impersonation",
41
+ "authentication-results",
42
+ "dmarc",
43
+ "dkim",
44
+ "spf",
45
+ "rfc8601",
46
+ "rfc5322",
47
+ "plus-addressing",
48
+ "broberg"
49
+ ],
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "https://github.com/broberg-ai/components",
53
+ "directory": "packages/mail-identity"
54
+ },
55
+ "publishConfig": {
56
+ "access": "public"
57
+ }
58
+ }