Check S/MIME signatures, and remember who signed

A signed message now says whether that holds up, as it is read. This is
verification only: nothing here signs, encrypts or decrypts, and the
private-key question that blocks those is untouched. Verifying needed
none of it, because the certificate travels inside the message -- which
is why this is the half that could be built.

What it checks. For multipart/signed carrying PKCS#7, the exact bytes of
the signed part -- headers included, canonicalised to CRLF -- are hashed
against the messageDigest attribute, and the signature over the signed
attributes is verified with WebCrypto against the certificate inside the
message. RSA PKCS#1 v1.5 and ECDSA over P-256/384/521, with SHA-256, 384
or 512.

The trust model is the design, and it is deliberately small. A browser
has no system trust store, and the certificate arrives inside the
message, so anyone can self-sign as anyone: on its own a good signature
shows only that the sender held the key they attached. So the word
"verified" is never rendered, and the reassuring case is not the loud
one. What carries the weight is remembering -- the first signed message
from an address pins its fingerprint, later ones are compared, and a
signer that changed is reported with both names and told to check by
another route. Trust on first use, no certificate authority anywhere.

The pins live in the account's settings rather than the browser: one
that only a single device knew would greet the same correspondent as new
everywhere else, which is how people are trained to click past the one
warning that matters. A pin records the message that created it, so the
message that established a signer keeps saying so instead of appearing
to be corroborated by itself -- without that, the very first signed
message anybody receives reads as "the same signer as before", where
before is itself. A changed, mismatched or expired signer is never
pinned, since writing the anomaly into the baseline makes every later
message agree with it.

Three things are declined rather than attempted, and all three say
"could not check" rather than "does not check out", because ignorance
and an accusation are different claims:

  - OpenPGP, by name. The signature carries no key and there is nowhere
    to get the sender's: x:PublicKey is the account's OWN registry, and
    a keyserver or WKD lookup would tell a third party who you
    correspond with -- the leak the image proxy exists to close.
  - SHA-1. Not forgeable in practice today, still not something to put a
    tick beside.
  - RSA-PSS, whose salt length lives in parameters this does not read.
    Guessing wrong would report a good signature as bad.

Nothing validates a chain: no CA bundle is shipped and revocation is not
checked. "Issued by" reports what the certificate claims, and a
self-signed one claims itself.

The DER, CMS, X.509 and MIME readers are hand-written and deliberately
narrow -- no new dependency, and the whole verifier is a lazily imported
8.6 kB chunk that a reader of unsigned mail never downloads. The one
place this is easy to get quietly wrong has its own function and its own
test: signed attributes are signed as a SET OF, not as the [0] IMPLICIT
they arrive as, and hashing the message instead would make every
signature "pass".

Tested against real `openssl smime -sign` output rather than hand-built
fixtures -- RSA, ECDSA, a tampered copy, and a valid signature by a
certificate for somebody else -- because a signed message written by
hand only agrees with whatever its author believed the format to be.
Also driven in a browser against the mock, which now serves three real
signed messages so every branch of the banner is reachable.

Translations: 34 new strings in all nine catalogues, 306 entries.
Falling back to English is unchanged at 24 per language.
This commit is contained in:
2026-09-05 01:42:51 -07:00
parent 7aa2e374d4
commit c84f190f76
31 changed files with 2287 additions and 4 deletions
+4
View File
@@ -10,6 +10,8 @@ import { useContacts } from "@/store/contacts";
import { useCalendar } from "@/store/calendar";
import { startAppointment } from "@/lib/appointment";
import { client } from "@/jmap/client";
import { SignatureBanner } from "./SignatureBanner";
import { useSignature } from "@/lib/smime/useSignature";
import { emlFilename } from "@/lib/emlName";
import { isTnef, parseTnef, type TnefAttachment } from "@/lib/tnef";
import { internalDomains, isExternalSender, linkVerdict } from "@/lib/warnings";
@@ -47,6 +49,7 @@ interface Props {
export const MessageView = memo(function MessageView({ email: e, expanded, wasUnread, onToggle, actions }: Props) {
const accountId = useMail((s) => s.accountId)!;
const signature = useSignature(e, accountId);
const settings = useSettings((s) => s.settings);
const updateSettings = useSettings((s) => s.update);
@@ -387,6 +390,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
</button>
</div>
)}
<SignatureBanner state={signature} />
{externalSender && (
<div className="remote-banner external-banner" style={{ margin: "0 16px 8px" }}>
<ShieldAlert size={16} />
+162
View File
@@ -0,0 +1,162 @@
import { useState } from "react";
import { BadgeCheck, ShieldAlert, ShieldQuestion, ShieldX } from "lucide-react";
import { formatFingerprint } from "@/lib/smime/x509";
import type { SignatureState } from "@/lib/smime/useSignature";
import type { Reason } from "@/lib/smime/verify";
import { t, tNode } from "@/lib/i18n";
import { formatFullDate } from "@/lib/format";
/**
* What a checked signature is allowed to say on screen.
*
* The wording here is the feature. ihasmail has no certificate authority to ask
* and none is bundled, so the strong word — "verified", full stop — is never
* used: the certificate arrives inside the message, and on its own a good
* signature only shows that whoever wrote the message held the key attached to
* it. What can honestly be said is whether this is the same signer as last
* time, and that is what the banner leads with.
*
* Which means the *reassuring* case is deliberately the quiet one and the
* changed-signer case is the loud one. A green tick on a first sighting would
* be telling somebody that an unknown certificate is trustworthy because it
* verified against itself.
*/
export function SignatureBanner({ state }: { state: SignatureState }) {
const [open, setOpen] = useState(false);
if (state.status !== "done") return null;
const { crypto, trust, previous, warnings } = state.report;
if (crypto.kind === "none") return null;
if (crypto.kind === "unsupported") {
return (
<Banner tone="quiet" icon={<ShieldQuestion size={16} />}>
<span className="grow">
{t("This message is signed, and ihasmail could not check the signature.")} {explain(crypto.reason)}
{crypto.detail && <span className="hint"> {crypto.detail}</span>}
</span>
</Banner>
);
}
if (crypto.kind === "broken") {
return (
<Banner tone="danger" icon={<ShieldX size={16} />}>
<span className="grow">
<strong>{t("This signature does not check out.")}</strong> {explain(crypto.reason)}
</span>
</Banner>
);
}
const name = crypto.cert.subject.commonName || crypto.cert.emails[0] || t("an unnamed signer");
const changed = trust === "changed";
const mismatch = warnings.includes("address-mismatch");
const tone = changed || mismatch ? "danger" : warnings.length > 0 ? "warn" : trust === "same-as-before" ? "good" : "quiet";
return (
<Banner tone={tone} icon={changed || mismatch ? <ShieldAlert size={16} /> : trust === "same-as-before" ? <BadgeCheck size={16} /> : <ShieldQuestion size={16} />}>
<span className="grow">
{changed ? (
<>
<strong>{t("The signer has changed.")}</strong>{" "}
{tNode("Earlier messages from this address were signed by {previous}. This one is signed by {current}.", {
previous: <strong className="notranslate" translate="no">{previous?.name ?? t("a different certificate")}</strong>,
current: <strong className="notranslate" translate="no">{name}</strong>,
})}{" "}
{t("That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.")}
</>
) : mismatch ? (
<>
<strong>{t("The signature is not for this sender.")}</strong>{" "}
{tNode("It was made with a certificate belonging to {name}, which does not cover this address.", {
name: <strong className="notranslate" translate="no">{name}</strong>,
})}
</>
) : trust === "same-as-before" ? (
tNode("Signed by {name} — the same signer as before.", { name: <strong className="notranslate" translate="no">{name}</strong> })
) : (
<>
{tNode("Signed by {name}, seen here for the first time.", { name: <strong className="notranslate" translate="no">{name}</strong> })}{" "}
{t("ihasmail will tell you if a later message from this address is signed by anybody else.")}
</>
)}
{warnings.includes("certificate-expired") && <> {t("The certificate has expired.")}</>}
{warnings.includes("certificate-not-yet-valid") && <> {t("The certificate is not valid yet.")}</>}
</span>
<button onClick={() => setOpen(!open)}>{open ? t("Hide details") : t("Details")}</button>
{open && (
<table className="sessions-table" style={{ marginTop: 8, width: "100%" }}>
<tbody>
<tr>
<td>{t("Signer")}</td>
<td className="notranslate" translate="no">{name}</td>
</tr>
<tr>
<td>{t("Certificate covers")}</td>
<td className="notranslate" translate="no">{crypto.cert.emails.join(", ") || t("no address")}</td>
</tr>
<tr>
<td>{t("Issued by")}</td>
<td className="notranslate" translate="no">{crypto.cert.issuer.commonName || crypto.cert.issuer.organization || t("itself, or an issuer it does not name")}</td>
</tr>
<tr>
<td>{t("Valid until")}</td>
<td>{formatFullDate(crypto.cert.notAfter.toISOString())}</td>
</tr>
{crypto.signer.signingTime && (
<tr>
<td>{t("Signed at")}</td>
<td>
{formatFullDate(crypto.signer.signingTime.toISOString())} <span className="hint">{t("as claimed by the signer")}</span>
</td>
</tr>
)}
<tr>
<td>{t("Fingerprint")}</td>
<td className="mono" style={{ fontSize: ".8em", wordBreak: "break-all" }}>
{formatFingerprint(crypto.cert.fingerprint)}
</td>
</tr>
{previous && (
<tr>
<td>{t("Previous fingerprint")}</td>
<td className="mono" style={{ fontSize: ".8em", wordBreak: "break-all" }}>
{formatFingerprint(previous.fingerprint)} <span className="hint">{t("first seen {date}", { date: formatFullDate(previous.firstSeen) })}</span>
</td>
</tr>
)}
</tbody>
</table>
)}
</Banner>
);
}
/** The sayable version of why a check did not happen, or did not hold. */
function explain(reason: Reason): string {
switch (reason) {
case "openpgp":
return t("It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.");
case "rsa-pss":
return t("It uses a signature algorithm ihasmail cannot check yet.");
case "no-certificate":
return t("The signature carries no certificate that can be read.");
case "not-signed-properly":
return t("The signed part is missing either the message or the signature.");
case "digest-mismatch":
return t("The message does not match what was signed — it was altered after signing, or damaged on the way.");
case "signature-mismatch":
return t("The signature does not match the certificate sent with it.");
case "other":
return t("The signature could not be read.");
}
}
function Banner({ tone, icon, children }: { tone: "good" | "warn" | "danger" | "quiet"; icon: React.ReactNode; children: React.ReactNode }) {
return (
<div className={`remote-banner signature-banner ${tone}`} style={{ margin: "0 16px 8px" }}>
{icon}
{children}
</div>
);
}
@@ -0,0 +1,130 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { SignatureBanner } from "../SignatureBanner";
import type { SignatureState } from "@/lib/smime/useSignature";
import type { Certificate } from "@/lib/smime/x509";
import type { SignerInfo } from "@/lib/smime/cms";
import type { SignatureReport } from "@/lib/smime/verify";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
/*
* The wording is the feature here, so it is worth asserting rather than
* eyeballing. The rule this pins down: ihasmail has no certificate authority to
* ask, so a signature that merely verifies against the certificate travelling
* beside it must never be dressed as an endorsement. "Verified" full stop is
* the word that would be a lie, and the tone must not be the reassuring one
* until a previous sighting actually corroborates the signer.
*/
const cert = (over: Partial<Certificate> = {}): Certificate =>
({
fingerprint: "ab".repeat(32),
serial: "01",
subject: { commonName: "Ada Lovelace" },
issuer: { commonName: "Ada Lovelace" },
emails: ["[email protected]"],
notBefore: new Date("2026-01-01T00:00:00Z"),
notAfter: new Date("2030-01-01T00:00:00Z"),
spki: new Uint8Array(),
publicKey: { kind: "rsa" },
der: new Uint8Array(),
issuerDer: new Uint8Array(),
...over,
}) as Certificate;
const signer = { digest: "SHA-256" } as SignerInfo;
const done = (report: SignatureReport): SignatureState => ({ status: "done", report });
describe("what the signature banner says", () => {
let host: HTMLDivElement;
let root: Root;
const render = async (state: SignatureState) => {
await act(async () => {
root.render(<SignatureBanner state={state} />);
});
};
beforeEach(() => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
it("shows nothing at all for an unsigned message", async () => {
await render(done({ crypto: { kind: "none" }, warnings: [] }));
expect(host.textContent).toBe("");
});
it("shows nothing while the check is still running", async () => {
await render({ status: "checking" });
expect(host.textContent).toBe("");
});
it("does not congratulate a signer it has never seen before", async () => {
await render(done({ crypto: { kind: "intact", cert: cert(), signer }, trust: "first-seen", warnings: [] }));
expect(host.textContent).toContain("seen here for the first time");
// Grey, not green: an unknown certificate that verifies against itself has
// established nothing worth a tick.
expect(host.querySelector(".signature-banner")?.className).toContain("quiet");
expect(host.textContent).not.toMatch(/\bverified\b/i);
});
it("keeps green for the one case that earned it", async () => {
await render(done({ crypto: { kind: "intact", cert: cert(), signer }, trust: "same-as-before", warnings: [] }));
expect(host.textContent).toContain("the same signer as before");
expect(host.querySelector(".signature-banner")?.className).toContain("good");
});
it("is loud when the signer changed, and names both", async () => {
await render(
done({
crypto: { kind: "intact", cert: cert({ subject: { commonName: "Somebody Else" } }), signer },
trust: "changed",
previous: { fingerprint: "cd".repeat(32), name: "Ada Lovelace", firstSeen: "2026-09-01T00:00:00Z" },
warnings: [],
}),
);
expect(host.querySelector(".signature-banner")?.className).toContain("danger");
expect(host.textContent).toContain("The signer has changed");
expect(host.textContent).toContain("Ada Lovelace");
expect(host.textContent).toContain("Somebody Else");
// And tells the reader what to do about it, rather than only that it happened.
expect(host.textContent).toMatch(/some other route/);
});
it("is loud when a good signature is by a certificate for somebody else", async () => {
await render(done({ crypto: { kind: "intact", cert: cert({ emails: ["[email protected]"] }), signer }, trust: "first-seen", warnings: ["address-mismatch"] }));
expect(host.querySelector(".signature-banner")?.className).toContain("danger");
expect(host.textContent).toContain("not for this sender");
});
it("calls a failed check a failed check", async () => {
await render(done({ crypto: { kind: "broken", reason: "digest-mismatch" }, warnings: [] }));
expect(host.querySelector(".signature-banner")?.className).toContain("danger");
expect(host.textContent).toContain("does not check out");
expect(host.textContent).toContain("altered after signing");
});
it("says it could not check, rather than that the signature is bad", async () => {
await render(done({ crypto: { kind: "unsupported", reason: "openpgp" }, warnings: [] }));
expect(host.querySelector(".signature-banner")?.className).toContain("quiet");
expect(host.textContent).toContain("could not check the signature");
expect(host.textContent).toContain("OpenPGP");
// The distinction that matters: "cannot check" is not "does not check out".
expect(host.textContent).not.toContain("does not check out");
});
it("mentions an expired certificate without downgrading the signature", async () => {
await render(done({ crypto: { kind: "intact", cert: cert(), signer }, trust: "same-as-before", warnings: ["certificate-expired"] }));
expect(host.textContent).toContain("The certificate has expired.");
expect(host.querySelector(".signature-banner")?.className).toContain("warn");
});
});