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:
@@ -0,0 +1,165 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { judge, shouldRemember, verifyMessage, type KnownSigner } from "../verify";
|
||||
import { certCovers } from "../x509";
|
||||
import { parseMime, toCanonicalCrlf } from "../mime";
|
||||
|
||||
/**
|
||||
* These fixtures are real. Each was produced by `openssl smime -sign` against a
|
||||
* generated certificate, not written by hand — a hand-built signed message
|
||||
* tests the parser against the author's belief about the format, agrees with
|
||||
* every mistake in it, and is exactly how a verifier ends up passing its own
|
||||
* suite and failing on the first message anybody actually sends.
|
||||
*
|
||||
* The tampered fixture is the same signed message with one word of the body
|
||||
* changed and the signature left alone, which is the case the whole feature
|
||||
* exists to catch.
|
||||
*/
|
||||
// Read through the filesystem rather than an import, so the bytes arrive
|
||||
// exactly as they were signed. A bundler transform in the middle -- even one
|
||||
// that only touched line endings -- would be testing the transform.
|
||||
const fixture = (name: string) => new Uint8Array(readFileSync(resolve(__dirname, "fixtures", name)));
|
||||
|
||||
describe("a genuinely signed message", () => {
|
||||
it("verifies an RSA signature and reads the signer off the certificate", async () => {
|
||||
const result = await verifyMessage(fixture("signed-rsa.eml"));
|
||||
expect(result.kind).toBe("intact");
|
||||
if (result.kind !== "intact") return;
|
||||
expect(result.cert.subject.commonName).toBe("Ada Lovelace");
|
||||
expect(result.cert.emails).toContain("[email protected]");
|
||||
expect(result.cert.fingerprint).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(result.signer.digest).toBe("SHA-256");
|
||||
});
|
||||
|
||||
it("verifies an ECDSA signature, whose r and s need re-packing for WebCrypto", async () => {
|
||||
const result = await verifyMessage(fixture("signed-ec.eml"));
|
||||
expect(result.kind).toBe("intact");
|
||||
if (result.kind !== "intact") return;
|
||||
expect(result.cert.subject.commonName).toBe("Grace Hopper");
|
||||
expect(result.cert.publicKey).toEqual({ kind: "ec", namedCurve: "P-256" });
|
||||
});
|
||||
|
||||
it("catches a body edited after signing", async () => {
|
||||
const result = await verifyMessage(fixture("signed-tampered.eml"));
|
||||
expect(result.kind).toBe("broken");
|
||||
if (result.kind !== "broken") return;
|
||||
expect(result.reason).toBe("digest-mismatch");
|
||||
});
|
||||
|
||||
it("says nothing is signed when nothing is", async () => {
|
||||
const plain = new TextEncoder().encode("From: [email protected]\r\nSubject: hi\r\n\r\nJust text.\r\n");
|
||||
expect((await verifyMessage(plain)).kind).toBe("none");
|
||||
});
|
||||
|
||||
it("declines OpenPGP by name, rather than as an unknown format", async () => {
|
||||
const pgp = new TextEncoder().encode(
|
||||
'From: [email protected]\r\nContent-Type: multipart/signed; protocol="application/pgp-signature"; boundary="b"\r\n\r\n--b\r\nContent-Type: text/plain\r\n\r\nhi\r\n--b\r\nContent-Type: application/pgp-signature\r\n\r\nsig\r\n--b--\r\n',
|
||||
);
|
||||
const result = await verifyMessage(pgp);
|
||||
expect(result.kind).toBe("unsupported");
|
||||
if (result.kind !== "unsupported") return;
|
||||
// A code, so the sentence can be translated where it is shown.
|
||||
expect(result.reason).toBe("openpgp");
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the signature is allowed to mean", () => {
|
||||
const ada = "[email protected]";
|
||||
|
||||
it("a first sighting is pinned, and says so", async () => {
|
||||
const crypto = await verifyMessage(fixture("signed-rsa.eml"));
|
||||
const report = judge(crypto, ada, undefined);
|
||||
expect(report.trust).toBe("first-seen");
|
||||
expect(report.warnings).toEqual([]);
|
||||
expect(shouldRemember(report)).toBe(true);
|
||||
});
|
||||
|
||||
it("the same certificate again is recognised", async () => {
|
||||
const crypto = await verifyMessage(fixture("signed-rsa.eml"));
|
||||
if (crypto.kind !== "intact") throw new Error("fixture should verify");
|
||||
const known: KnownSigner = { fingerprint: crypto.cert.fingerprint, name: "Ada Lovelace", firstSeen: "2026-09-01T00:00:00Z" };
|
||||
const report = judge(crypto, ada, known);
|
||||
expect(report.trust).toBe("same-as-before");
|
||||
// Nothing to write: it already matches what is stored.
|
||||
expect(shouldRemember(report)).toBe(false);
|
||||
});
|
||||
|
||||
it("a different certificate for a known address is the loud case", async () => {
|
||||
const crypto = await verifyMessage(fixture("signed-rsa.eml"));
|
||||
const known: KnownSigner = { fingerprint: "0".repeat(64), name: "Ada Lovelace", firstSeen: "2026-09-01T00:00:00Z" };
|
||||
const report = judge(crypto, ada, known);
|
||||
expect(report.trust).toBe("changed");
|
||||
expect(report.previous).toBe(known);
|
||||
// A changed signer must never overwrite the pin -- that would launder the
|
||||
// very substitution this is here to report.
|
||||
expect(shouldRemember(report)).toBe(false);
|
||||
});
|
||||
|
||||
it("notices a valid signature by a certificate for somebody else", async () => {
|
||||
const crypto = await verifyMessage(fixture("signed-wrong-address.eml"));
|
||||
expect(crypto.kind).toBe("intact");
|
||||
const report = judge(crypto, ada, undefined);
|
||||
expect(report.warnings).toContain("address-mismatch");
|
||||
// Cryptographically fine, and still not to be pinned as Ada's signer.
|
||||
expect(shouldRemember(report)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports an expired certificate without calling the signature broken", async () => {
|
||||
const crypto = await verifyMessage(fixture("signed-rsa.eml"));
|
||||
const report = judge(crypto, ada, undefined, new Date("2099-01-01T00:00:00Z"));
|
||||
expect(report.crypto.kind).toBe("intact");
|
||||
expect(report.warnings).toContain("certificate-expired");
|
||||
expect(shouldRemember(report)).toBe(false);
|
||||
});
|
||||
|
||||
it("passes a non-verifying result straight through with no trust claim", () => {
|
||||
const report = judge({ kind: "broken", reason: "signature-mismatch" }, ada, undefined);
|
||||
expect(report.trust).toBeUndefined();
|
||||
expect(shouldRemember(report)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matching a certificate to an address", () => {
|
||||
it("is case-insensitive, as addresses are", async () => {
|
||||
const crypto = await verifyMessage(fixture("signed-rsa.eml"));
|
||||
if (crypto.kind !== "intact") throw new Error("fixture should verify");
|
||||
expect(certCovers(crypto.cert, "[email protected]")).toBe(true);
|
||||
expect(certCovers(crypto.cert, "[email protected]")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canonicalisation", () => {
|
||||
it("turns a lone LF into CRLF and leaves an existing CRLF alone", () => {
|
||||
const mixed = new TextEncoder().encode("a\nb\r\nc\n");
|
||||
expect(new TextDecoder().decode(toCanonicalCrlf(mixed))).toBe("a\r\nb\r\nc\r\n");
|
||||
});
|
||||
|
||||
it("is a no-op on content that is already canonical", () => {
|
||||
const already = new TextEncoder().encode("a\r\nb\r\n");
|
||||
expect(toCanonicalCrlf(already)).toBe(already);
|
||||
});
|
||||
|
||||
/*
|
||||
* The reason canonicalisation is applied at all: a store that hands back a
|
||||
* message with bare LFs would otherwise fail every signature it holds, and
|
||||
* the message would look identical on screen while doing it.
|
||||
*/
|
||||
it("verifies a signed message whose line endings were flattened in storage", async () => {
|
||||
const original = fixture("signed-rsa.eml");
|
||||
const flattened = new TextEncoder().encode(new TextDecoder().decode(original).replace(/\r\n/g, "\n"));
|
||||
expect((await verifyMessage(flattened)).kind).toBe("intact");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reading the message structure", () => {
|
||||
it("finds the two parts of a signed message and keeps their bytes intact", () => {
|
||||
const root = parseMime(fixture("signed-rsa.eml"));
|
||||
expect(root.contentType).toBe("multipart/signed");
|
||||
expect(root.parts).toHaveLength(2);
|
||||
expect(root.parts[0]!.contentType).toBe("text/plain");
|
||||
expect(root.parts[1]!.contentType).toBe("application/x-pkcs7-signature");
|
||||
// The signed part keeps its own headers: they are inside what was signed.
|
||||
expect(new TextDecoder().decode(root.parts[0]!.raw)).toMatch(/^Content-Type: text\/plain/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user