Skip to content

Commit 6737e33

Browse files
committed
bimi: support BIMI declination records
The BIMI draft lets a domain explicitly opt out of BIMI by publishing a record with v=BIMI1 and an empty l= tag. Surface that in the editor and treat it as a first-class case in compliance.
1 parent 04e072a commit 6737e33

7 files changed

Lines changed: 180 additions & 64 deletions

File tree

web/src/lib/components/services/editors/svcs.BIMI.svelte

Lines changed: 68 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,18 @@
2222
-->
2323

2424
<script lang="ts">
25+
import { FormGroup, Input } from "@sveltestrap/sveltestrap";
26+
2527
import type { Domain } from "$lib/model/domain";
2628
import BasicInput from "$lib/components/inputs/basic.svelte";
2729
import type { dnsResource } from "$lib/dns_rr";
2830
import { getRrtype, newRR } from "$lib/dns_rr";
29-
import { parseBIMI, stringifyBIMI } from "$lib/services/bimi";
31+
import {
32+
isBIMIDeclination,
33+
parseBIMI,
34+
stringifyBIMI,
35+
stringifyBIMIDeclination,
36+
} from "$lib/services/bimi";
3037
3138
interface Props {
3239
dn: string;
@@ -40,14 +47,20 @@
4047
value["txt"] = newRR("default._bimi", getRrtype("TXT")) as any;
4148
}
4249
43-
let val = $state(parseBIMI(value["txt"]!.Txt || ""));
50+
let initialTxt = value["txt"]!.Txt || "";
51+
let val = $state(parseBIMI(initialTxt));
4452
let selector = $state(
4553
value["txt"]!.Hdr?.Name?.replace("._bimi", "") || "default",
4654
);
55+
let decline = $state(isBIMIDeclination(initialTxt));
4756
4857
$effect(() => {
4958
const txt = value["txt"]!;
50-
txt.Txt = stringifyBIMI(val, txt.Txt || "");
59+
if (decline) {
60+
txt.Txt = stringifyBIMIDeclination(val.v || "BIMI1");
61+
} else {
62+
txt.Txt = stringifyBIMI(val, txt.Txt || "");
63+
}
5164
if (txt.Hdr) {
5265
txt.Hdr.Name = selector + "._bimi";
5366
}
@@ -86,43 +99,57 @@
8699
bind:value={selector}
87100
/>
88101

89-
<BasicInput
90-
edit
91-
index="l"
92-
specs={{
93-
id: "l",
94-
label: "Logo",
95-
placeholder: "https://example.com/logo.svg",
96-
type: "string",
97-
description: "HTTPS URL of the SVG Tiny Portable/Secure logo.",
98-
}}
99-
bind:value={val.l}
100-
/>
101-
102-
<BasicInput
103-
edit
104-
index="a"
105-
specs={{
106-
id: "a",
107-
label: "Authority",
108-
placeholder: "https://example.com/vmc.pem",
109-
type: "string",
110-
description: "HTTPS URL of the Verified Mark Certificate (PEM). Required by Gmail and Yahoo.",
111-
}}
112-
bind:value={val.a}
113-
/>
114-
115-
<BasicInput
116-
edit
117-
index="e"
118-
specs={{
119-
id: "e",
120-
label: "Evidence",
121-
placeholder: "https://example.com/evidence",
122-
type: "string",
123-
description: "HTTPS URL of an evidence document (optional).",
124-
}}
125-
bind:value={val.e}
126-
/>
102+
<FormGroup>
103+
<Input
104+
id="bimi-decline"
105+
type="checkbox"
106+
label="Decline to participate in BIMI"
107+
bind:checked={decline}
108+
/>
109+
<small class="form-text text-muted">
110+
Publishes an explicit empty record so receivers know your domain has chosen not to participate in BIMI.
111+
</small>
112+
</FormGroup>
113+
114+
{#if !decline}
115+
<BasicInput
116+
edit
117+
index="l"
118+
specs={{
119+
id: "l",
120+
label: "Logo",
121+
placeholder: "https://example.com/logo.svg",
122+
type: "string",
123+
description: "HTTPS URL of the SVG Tiny Portable/Secure logo.",
124+
}}
125+
bind:value={val.l}
126+
/>
127+
128+
<BasicInput
129+
edit
130+
index="a"
131+
specs={{
132+
id: "a",
133+
label: "Authority",
134+
placeholder: "https://example.com/vmc.pem",
135+
type: "string",
136+
description: "HTTPS URL of the Verified Mark Certificate (PEM). Required by Gmail and Yahoo.",
137+
}}
138+
bind:value={val.a}
139+
/>
140+
141+
<BasicInput
142+
edit
143+
index="e"
144+
specs={{
145+
id: "e",
146+
label: "Evidence",
147+
placeholder: "https://example.com/evidence",
148+
type: "string",
149+
description: "HTTPS URL of an evidence document (optional).",
150+
}}
151+
bind:value={val.e}
152+
/>
153+
{/if}
127154
</form>
128155
</div>

web/src/lib/locales/en.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -896,6 +896,10 @@
896896
"title": "No Verified Mark Certificate (a=)",
897897
"detail": "Gmail and Yahoo require a VMC to display the BIMI logo."
898898
},
899+
"declination": {
900+
"title": "Declination to participate in BIMI",
901+
"detail": "This record explicitly tells receivers that the domain has chosen not to participate in BIMI."
902+
},
899903
"no-dmarc": {
900904
"title": "No DMARC record published",
901905
"detail": "BIMI requires a DMARC record with an enforcing policy (quarantine or reject)."

web/src/lib/locales/fr.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,10 @@
667667
"title": "Pas de Verified Mark Certificate (a=)",
668668
"detail": "Gmail et Yahoo nécessitent un VMC pour afficher le logo BIMI."
669669
},
670+
"declination": {
671+
"title": "Déclaration de non-participation à BIMI",
672+
"detail": "Cet enregistrement indique explicitement aux destinataires que le domaine a choisi de ne pas participer à BIMI."
673+
},
670674
"no-dmarc": {
671675
"title": "Aucun enregistrement DMARC publié",
672676
"detail": "BIMI nécessite un enregistrement DMARC avec une politique stricte (quarantine ou reject)."

web/src/lib/services/bimi.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
// along with this program. If not, see <https://www.gnu.org/licenses/>.
2121

2222
import { describe, it, expect } from "vitest";
23-
import { parseBIMI, stringifyBIMI } from "./bimi";
23+
import { isBIMIDeclination, parseBIMI, stringifyBIMI, stringifyBIMIDeclination } from "./bimi";
2424

2525
describe("parseBIMI", () => {
2626
it("parses a minimal BIMI record", () => {
@@ -97,6 +97,24 @@ describe("stringifyBIMI", () => {
9797
});
9898
});
9999

100+
describe("isBIMIDeclination", () => {
101+
it("recognises v=BIMI1;l= as declination", () => {
102+
expect(isBIMIDeclination("v=BIMI1;l=")).toBe(true);
103+
});
104+
it("recognises v=BIMI1; l= ; a= as declination", () => {
105+
expect(isBIMIDeclination("v=BIMI1; l= ; a=")).toBe(true);
106+
});
107+
it("rejects a regular BIMI record", () => {
108+
expect(isBIMIDeclination("v=BIMI1;l=https://example.com/logo.svg")).toBe(false);
109+
});
110+
it("rejects a non-BIMI record", () => {
111+
expect(isBIMIDeclination("v=DMARC1;l=")).toBe(false);
112+
});
113+
it("recognises stringifyBIMIDeclination output", () => {
114+
expect(isBIMIDeclination(stringifyBIMIDeclination())).toBe(true);
115+
});
116+
});
117+
100118
describe("parseBIMI and stringifyBIMI roundtrip", () => {
101119
it("preserves all fields through a full roundtrip", () => {
102120
const original =

web/src/lib/services/bimi.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,20 @@ export function parseBIMI(val: string): BIMIValue {
3838
};
3939
}
4040

41+
/**
42+
* Detects a BIMI declination record. Per the BIMI draft, a domain that does
43+
* not wish to participate publishes a record with v=BIMI1 and an explicitly
44+
* empty l= tag.
45+
*/
46+
export function isBIMIDeclination(val: string): boolean {
47+
if (!/(?:^|;)\s*v\s*=\s*BIMI\d+/i.test(val)) return false;
48+
return /(?:^|;)\s*l\s*=\s*(?:;|$)/i.test(val);
49+
}
50+
51+
export function stringifyBIMIDeclination(version: string = "BIMI1"): string {
52+
return `v=${version};l=`;
53+
}
54+
4155
export function stringifyBIMI(val: BIMIValue, existingTxt: string = ""): string {
4256
const sep = existingTxt.indexOf("; ") >= 0 ? "; " : ";";
4357

web/src/lib/services/bimi/compliance.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,37 @@ describe("BIMI compliance: authority & evidence", () => {
144144
});
145145
});
146146

147+
describe("BIMI compliance: declination", () => {
148+
it("emits only the declination info for v=BIMI1;l= (no zone)", () => {
149+
const issues = run("default._bimi", "v=BIMI1;l=");
150+
expect(ids(issues)).toEqual(["bimi.declination"]);
151+
});
152+
it("declination skips URL/VMC checks but keeps DMARC cross-checks", () => {
153+
const zone = zoneWith();
154+
const issues = run("default._bimi", "v=BIMI1;l=", zone);
155+
expect(ids(issues)).toContain("bimi.declination");
156+
expect(ids(issues)).toContain("bimi.no-dmarc");
157+
expect(ids(issues)).not.toContain("bimi.missing-location");
158+
expect(ids(issues)).not.toContain("bimi.missing-vmc");
159+
});
160+
it("declination flags weak DMARC policy", () => {
161+
const zone = zoneWith(dmarcService("v=DMARC1; p=none"));
162+
const issues = run("default._bimi", "v=BIMI1;l=", zone);
163+
expect(ids(issues)).toContain("bimi.declination");
164+
expect(ids(issues)).toContain("bimi.weak-dmarc-policy");
165+
});
166+
it("declination is fully clean with an enforcing DMARC", () => {
167+
const zone = zoneWith(dmarcService("v=DMARC1; p=reject"));
168+
const issues = run("default._bimi", "v=BIMI1;l=", zone);
169+
expect(ids(issues)).toEqual(["bimi.declination"]);
170+
});
171+
it("still flags an invalid selector in declination mode", () => {
172+
const issues = run("bad selector._bimi", "v=BIMI1;l=");
173+
expect(ids(issues)).toContain("bimi.invalid-selector");
174+
expect(ids(issues)).toContain("bimi.declination");
175+
});
176+
});
177+
147178
describe("BIMI compliance: DMARC cross-checks", () => {
148179
it("warns when no DMARC is published", () => {
149180
const zone = zoneWith();

web/src/lib/services/bimi/compliance.ts

Lines changed: 40 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
type ComplianceIssue,
2525
registerValidators,
2626
} from "$lib/services/compliance";
27-
import { parseBIMI } from "$lib/services/bimi";
27+
import { isBIMIDeclination, parseBIMI } from "$lib/services/bimi";
2828
import { parseDMARC } from "$lib/services/dmarc";
2929

3030
const SELECTOR_LABEL_RE = /^[A-Za-z0-9_-]+$/;
@@ -88,32 +88,50 @@ function bimiSync(raw: Record<string, any>, ctx: ComplianceContext): ComplianceI
8888
});
8989
}
9090

91-
// l= (Location) is mandatory.
92-
if (!val.l) {
91+
// Declination: a domain that does not wish to participate publishes
92+
// v=BIMI1 with an empty l= tag. The URL/VMC checks no longer apply,
93+
// but the DMARC cross-check still does: a declining domain still has
94+
// to back the declination with an enforcing DMARC policy, otherwise
95+
// an attacker could spoof the domain and override the declination.
96+
const declination = isBIMIDeclination(txtValue);
97+
if (declination) {
9398
issues.push({
94-
id: "bimi.missing-location",
95-
severity: "error",
96-
field: "l",
97-
docUrl: DRAFT,
98-
});
99-
} else if (!isHttps(val.l)) {
100-
issues.push({
101-
id: "bimi.location-not-https",
102-
severity: "error",
103-
field: "l",
104-
docUrl: DRAFT,
105-
});
106-
} else if (!/\.svg(\?|#|$)/i.test(val.l)) {
107-
issues.push({
108-
id: "bimi.location-not-svg",
109-
severity: "warning",
110-
field: "l",
99+
id: "bimi.declination",
100+
severity: "info",
111101
docUrl: DRAFT,
112102
});
113103
}
114104

105+
// l= (Location) is mandatory outside of declination.
106+
if (!declination) {
107+
if (!val.l) {
108+
issues.push({
109+
id: "bimi.missing-location",
110+
severity: "error",
111+
field: "l",
112+
docUrl: DRAFT,
113+
});
114+
} else if (!isHttps(val.l)) {
115+
issues.push({
116+
id: "bimi.location-not-https",
117+
severity: "error",
118+
field: "l",
119+
docUrl: DRAFT,
120+
});
121+
} else if (!/\.svg(\?|#|$)/i.test(val.l)) {
122+
issues.push({
123+
id: "bimi.location-not-svg",
124+
severity: "warning",
125+
field: "l",
126+
docUrl: DRAFT,
127+
});
128+
}
129+
}
130+
115131
// a= (Authority / VMC) is optional but strongly recommended.
116-
if (val.a) {
132+
if (declination) {
133+
// No-op: VMC is meaningless on a declination record.
134+
} else if (val.a) {
117135
if (!isHttps(val.a)) {
118136
issues.push({
119137
id: "bimi.authority-not-https",
@@ -139,7 +157,7 @@ function bimiSync(raw: Record<string, any>, ctx: ComplianceContext): ComplianceI
139157
}
140158

141159
// e= (Evidence) optional, must be HTTPS if present.
142-
if (val.e && !isHttps(val.e)) {
160+
if (!declination && val.e && !isHttps(val.e)) {
143161
issues.push({
144162
id: "bimi.evidence-not-https",
145163
severity: "warning",

0 commit comments

Comments
 (0)