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
@@ -0,0 +1,41 @@
To: [email protected]
From: Grace Hopper <[email protected]>
Subject: A note
MIME-Version: 1.0
Content-Type: multipart/signed; protocol="application/x-pkcs7-signature"; micalg="sha-256"; boundary="----1F14F13899656314019C2DF4C8728ED3"
This is an S/MIME signed message
------1F14F13899656314019C2DF4C8728ED3
Content-Type: text/plain; charset=utf-8
The Analytical Engine has no pretensions whatever to originate anything.
------1F14F13899656314019C2DF4C8728ED3
Content-Type: application/x-pkcs7-signature; name="smime.p7s"
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="smime.p7s"
MIIDtQYJKoZIhvcNAQcCoIIDpjCCA6ICAQExDzANBglghkgBZQMEAgEFADALBgkq
hkiG9w0BBwGgggHjMIIB3zCCAYagAwIBAgIUejcvY0YWeoAxAMTf4bp3jOkMCVww
CgYIKoZIzj0EAwIwKzEVMBMGA1UEAwwMR3JhY2UgSG9wcGVyMRIwEAYDVQQKDAlD
b21waWxlcnMwHhcNMjYwOTA1MDgyNzQ4WhcNMzYwOTAyMDgyNzQ4WjArMRUwEwYD
VQQDDAxHcmFjZSBIb3BwZXIxEjAQBgNVBAoMCUNvbXBpbGVyczBZMBMGByqGSM49
AgEGCCqGSM49AwEHA0IABOcBiRNb2IF0vlQJlnVKJXFr0taIz2LoU+8p0GQZ9kas
DnlHQaCR17JtDfGA6azi7w2ZOYZg0ZjGxbcKoTWKdR6jgYcwgYQwHQYDVR0OBBYE
FGqS/6/0hXUay/WvkZrl3DCTY1sTMB8GA1UdIwQYMBaAFGqS/6/0hXUay/WvkZrl
3DCTY1sTMA8GA1UdEwEB/wQFMAMBAf8wHAYDVR0RBBUwE4ERZ3JhY2VAZXhhbXBs
ZS5jb20wEwYDVR0lBAwwCgYIKwYBBQUHAwQwCgYIKoZIzj0EAwIDRwAwRAIgbJ+R
K6iGkQ6qrzT7m7E09D2rW+O3/LYoRPlwo5EJiZkCIHAvYgUyxqeRGNr/adeVmqvv
borP2nkAD8LjCUdtv1+NMYIBljCCAZICAQEwQzArMRUwEwYDVQQDDAxHcmFjZSBI
b3BwZXIxEjAQBgNVBAoMCUNvbXBpbGVycwIUejcvY0YWeoAxAMTf4bp3jOkMCVww
DQYJYIZIAWUDBAIBBQCggeQwGAYJKoZIhvcNAQkDMQsGCSqGSIb3DQEHATAcBgkq
hkiG9w0BCQUxDxcNMjYwOTA1MDgyNzQ4WjAvBgkqhkiG9w0BCQQxIgQgD6tNEuEW
5UlYvan88jdbR0HwFJnJxh0YtHUGA9NIyf8weQYJKoZIhvcNAQkPMWwwajALBglg
hkgBZQMEASowCwYJYIZIAWUDBAEWMAsGCWCGSAFlAwQBAjAKBggqhkiG9w0DBzAO
BggqhkiG9w0DAgICAIAwDQYIKoZIhvcNAwICAUAwBwYFKw4DAgcwDQYIKoZIhvcN
AwICASgwCgYIKoZIzj0EAwIERjBEAiBR36dlKMTvZKbufvpVZNylX7yxQrra+xHP
+KnbTB8rzAIgIW0ArgkEKCYF8eF76KyjOcfAlXT1MJKjiCjYj9I13Ug=
------1F14F13899656314019C2DF4C8728ED3--
@@ -0,0 +1,54 @@
To: [email protected]
From: Ada Lovelace <[email protected]>
Subject: A note
MIME-Version: 1.0
Content-Type: multipart/signed; protocol="application/x-pkcs7-signature"; micalg="sha-256"; boundary="----12D00EBC0B5D3352F0E2F25B1A5D3552"
This is an S/MIME signed message
------12D00EBC0B5D3352F0E2F25B1A5D3552
Content-Type: text/plain; charset=utf-8
The Analytical Engine has no pretensions whatever to originate anything.
------12D00EBC0B5D3352F0E2F25B1A5D3552
Content-Type: application/x-pkcs7-signature; name="smime.p7s"
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="smime.p7s"
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
------12D00EBC0B5D3352F0E2F25B1A5D3552--
@@ -0,0 +1,54 @@
To: [email protected]
From: Ada Lovelace <[email protected]>
Subject: A note
MIME-Version: 1.0
Content-Type: multipart/signed; protocol="application/x-pkcs7-signature"; micalg="sha-256"; boundary="----12D00EBC0B5D3352F0E2F25B1A5D3552"
This is an S/MIME signed message
------12D00EBC0B5D3352F0E2F25B1A5D3552
Content-Type: text/plain; charset=utf-8
The Analytical Engine has no pretensions whatsoever to originate anything.
------12D00EBC0B5D3352F0E2F25B1A5D3552
Content-Type: application/x-pkcs7-signature; name="smime.p7s"
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="smime.p7s"
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
------12D00EBC0B5D3352F0E2F25B1A5D3552--
@@ -0,0 +1,52 @@
To: [email protected]
From: Ada Lovelace <[email protected]>
Subject: Not really Ada
MIME-Version: 1.0
Content-Type: multipart/signed; protocol="application/x-pkcs7-signature"; micalg="sha-256"; boundary="----A376D763C7F4750971F7C04B27A38CA7"
This is an S/MIME signed message
------A376D763C7F4750971F7C04B27A38CA7
Content-Type: text/plain; charset=utf-8
The Analytical Engine has no pretensions whatever to originate anything.
------A376D763C7F4750971F7C04B27A38CA7
Content-Type: application/x-pkcs7-signature; name="smime.p7s"
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="smime.p7s"
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
------A376D763C7F4750971F7C04B27A38CA7--
+165
View File
@@ -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/);
});
});
+183
View File
@@ -0,0 +1,183 @@
/**
* CMS SignedData (RFC 5652), enough of it to check a detached S/MIME signature.
*
* The one place this is easy to get quietly wrong is what the signature covers.
* When signed attributes are present — and for S/MIME they always are, because
* the content type and message digest are required — the signature is **not**
* over the message. It is over the DER encoding of the SignedAttributes, and
* those appear in the blob tagged `[0] IMPLICIT`, which must be re-tagged to
* the universal `SET OF` before hashing. Skip that and every valid signature
* fails; hash the message instead and every signature "passes", which is very
* much worse. `signedAttrsForSigning` is that step, kept on its own so it can
* be tested on its own.
*/
import { at, children, DerError, expect, integerHex, oid, parse, TAG, time, type Asn1 } from "./der";
const OID = {
signedData: "1.2.840.113549.1.7.2",
data: "1.2.840.113549.1.7.1",
contentType: "1.2.840.113549.1.9.3",
messageDigest: "1.2.840.113549.1.9.4",
signingTime: "1.2.840.113549.1.9.5",
sha256: "2.16.840.1.101.3.4.2.1",
sha384: "2.16.840.1.101.3.4.2.2",
sha512: "2.16.840.1.101.3.4.2.3",
sha1: "1.3.14.3.2.26",
rsaEncryption: "1.2.840.113549.1.1.1",
sha256WithRsa: "1.2.840.113549.1.1.11",
sha384WithRsa: "1.2.840.113549.1.1.12",
sha512WithRsa: "1.2.840.113549.1.1.13",
rsaPss: "1.2.840.113549.1.1.10",
ecdsaWithSha256: "1.2.840.10045.4.3.2",
ecdsaWithSha384: "1.2.840.10045.4.3.3",
ecdsaWithSha512: "1.2.840.10045.4.3.4",
} as const;
export type Digest = "SHA-256" | "SHA-384" | "SHA-512";
export type SignatureKind = "rsa-pkcs1" | "rsa-pss" | "ecdsa";
export interface SignerInfo {
digest: Digest;
signature: SignatureKind;
/** Raw signature bytes. */
value: Uint8Array;
/** DER of the SignedAttributes, already re-tagged as a SET OF, ready to hash. */
signedAttrs: Uint8Array;
/** The messageDigest signed attribute: what the content must hash to. */
messageDigest: Uint8Array;
/** Claimed signing time, if the signer included one. Not evidence of anything. */
signingTime?: Date;
/** Issuer name DER + serial, how the signer's certificate is usually named. */
issuerDer?: Uint8Array;
serial?: string;
/** subjectKeyIdentifier, used instead of issuer-and-serial by version 3 signers. */
subjectKeyId?: Uint8Array;
}
export interface SignedData {
/** DER of each certificate carried along, in the order they appeared. */
certificates: Uint8Array[];
signers: SignerInfo[];
/** Present only for an opaque signature, where the content travels inside. */
encapsulatedContent?: Uint8Array;
}
/** Parse a PKCS#7 / CMS blob into the parts a verifier needs. */
export function parseSignedData(der: Uint8Array): SignedData {
const info = children(expect(parse(der), TAG.sequence, "a ContentInfo"));
if (oid(at(info, 0, "a content type")) !== OID.signedData) throw new DerError("Not a CMS SignedData.");
const wrapper = at(info, 1, "the SignedData [0]");
const signedData = children(expect(children(wrapper)[0] ?? wrapper, TAG.sequence, "a SignedData"));
// SignedData ::= version, digestAlgorithms, encapContentInfo,
// [0] certificates, [1] crls, signerInfos
const encap = children(expect(at(signedData, 2, "an encapContentInfo"), TAG.sequence, "an encapContentInfo"));
const encapsulatedContent = encap[1] ? children(encap[1])[0]?.content : undefined;
const certificates: Uint8Array[] = [];
const certSet = signedData.find((n) => n.cls === 2 && n.tag === 0);
if (certSet) {
for (const c of children(certSet)) {
// Only plain certificates; the other CHOICE arms are context-tagged and
// are attribute certificates, which nothing here knows how to read.
if (c.cls === 0 && c.tag === TAG.sequence) certificates.push(c.bytes);
}
}
const signerSet = signedData[signedData.length - 1];
if (!signerSet || signerSet.tag !== TAG.set) throw new DerError("No signerInfos.");
const signers = children(signerSet).map(readSigner);
if (signers.length === 0) throw new DerError("SignedData carries no signer.");
return { certificates, signers, encapsulatedContent };
}
function readSigner(node: Asn1): SignerInfo {
const p = children(expect(node, TAG.sequence, "a SignerInfo"));
let i = 1; // skip version
// sid ::= issuerAndSerialNumber | [0] subjectKeyIdentifier
const sid = at(p, i++, "a signer identifier");
let issuerDer: Uint8Array | undefined;
let serial: string | undefined;
let subjectKeyId: Uint8Array | undefined;
if (sid.cls === 2 && sid.tag === 0) {
subjectKeyId = sid.content;
} else {
const pair = children(sid);
issuerDer = at(pair, 0, "an issuer name").bytes;
serial = integerHex(at(pair, 1, "a serial number"));
}
const digest = digestFrom(oid(at(children(at(p, i++, "a digest algorithm")), 0, "a digest algorithm id")));
// [0] IMPLICIT SignedAttributes, optional but always present for S/MIME.
const attrsNode = p[i]?.cls === 2 && p[i]?.tag === 0 ? p[i++]! : undefined;
if (!attrsNode) throw new DerError("Signature carries no signed attributes; S/MIME requires them.");
const algNode = children(at(p, i++, "a signature algorithm"));
const signature = signatureFrom(oid(at(algNode, 0, "a signature algorithm id")));
const value = expect(at(p, i++, "a signature"), TAG.octetString, "a signature").content;
const attrs = children(attrsNode);
const messageDigest = findAttr(attrs, OID.messageDigest, (v) => expect(v, TAG.octetString, "a message digest").content);
if (!messageDigest) throw new DerError("Signature has no messageDigest attribute.");
const contentType = findAttr(attrs, OID.contentType, (v) => oid(v));
if (contentType && contentType !== OID.data) throw new DerError(`Signed content type is ${contentType}, not plain data.`);
// A claimed signing time is shown, never checked: the signer chose it, so it
// is a statement rather than evidence. An unreadable one must not fail the
// signature, which is why this swallows rather than throws.
const signingTime = findAttr(attrs, OID.signingTime, (v) => {
try {
return time(v);
} catch {
return undefined;
}
});
return { digest, signature, value, signedAttrs: signedAttrsForSigning(attrsNode), messageDigest, signingTime, issuerDer, serial, subjectKeyId };
}
/**
* The bytes the signature is actually over.
*
* SignedAttributes travel as `[0] IMPLICIT`, tag 0xA0. RFC 5652 §5.4 says the
* signature is computed over their DER encoding as a `SET OF`, tag 0x31. Only
* the identifier octet changes; the length and contents are already correct,
* which is why this is a single byte and also why it is so easy to miss.
*/
export function signedAttrsForSigning(attrs: Asn1): Uint8Array {
const copy = attrs.bytes.slice();
copy[0] = 0x31;
return copy;
}
function findAttr<T>(attrs: Asn1[], want: string, read: (v: Asn1) => T): T | undefined {
for (const attr of attrs) {
const kv = children(attr);
if (kv.length < 2) continue;
if (oid(at(kv, 0, "an attribute type")) !== want) continue;
const values = children(at(kv, 1, "an attribute value set"));
if (values[0]) return read(values[0]);
}
return undefined;
}
function digestFrom(o: string): Digest {
if (o === OID.sha256) return "SHA-256";
if (o === OID.sha384) return "SHA-384";
if (o === OID.sha512) return "SHA-512";
// SHA-1 is refused rather than supported. A signature nobody can forge in
// practice today is still one this should not be putting a tick beside.
if (o === OID.sha1) throw new DerError("Signed with SHA-1, which is too weak to report as verified.");
throw new DerError(`Unsupported digest algorithm ${o}.`);
}
function signatureFrom(o: string): SignatureKind {
if (o === OID.rsaEncryption || o === OID.sha256WithRsa || o === OID.sha384WithRsa || o === OID.sha512WithRsa) return "rsa-pkcs1";
if (o === OID.rsaPss) return "rsa-pss";
if (o === OID.ecdsaWithSha256 || o === OID.ecdsaWithSha384 || o === OID.ecdsaWithSha512) return "ecdsa";
throw new DerError(`Unsupported signature algorithm ${o}.`);
}
+224
View File
@@ -0,0 +1,224 @@
/**
* Just enough DER to read a CMS signature and an X.509 certificate.
*
* This is deliberately small. It is not a general ASN.1 library and should not
* grow into one: everything here exists because some byte of a signed message
* has to be looked at, and a parser that can read shapes nothing sends is a
* parser with corners nobody has tested.
*
* Two rules it keeps, both of which matter for verification rather than for
* tidiness:
*
* - Every node keeps `bytes`, the whole tag-length-value as it arrived. A
* signature is computed over encoded bytes, so anything that re-encodes a
* structure it means to hash has already lost. Nothing here re-encodes.
* - Indefinite lengths are refused rather than guessed at. S/MIME signatures
* are DER, which forbids them; a blob using one is either BER from an
* unusual producer or is not what it claims, and treating the two alike
* would mean inventing a parse for input this has never seen.
*/
export interface Asn1 {
/** Tag number, without the class and constructed bits. */
tag: number;
/** 0 universal, 1 application, 2 context-specific, 3 private. */
cls: number;
constructed: boolean;
/** Content octets: the V of TLV. */
content: Uint8Array;
/** The whole TLV as it arrived, for anything that must hash or re-present it. */
bytes: Uint8Array;
}
export const TAG = {
boolean: 0x01,
integer: 0x02,
bitString: 0x03,
octetString: 0x04,
null: 0x05,
oid: 0x06,
utf8String: 0x0c,
sequence: 0x10,
set: 0x11,
printableString: 0x13,
ia5String: 0x16,
utcTime: 0x17,
generalizedTime: 0x18,
bmpString: 0x1e,
} as const;
export class DerError extends Error {}
/** Read one TLV at `offset`. Returns the node and where the next one starts. */
export function readNode(buf: Uint8Array, offset = 0): { node: Asn1; next: number } {
if (offset + 2 > buf.length) throw new DerError("Truncated: no room for a tag and a length.");
const id = buf[offset]!;
const cls = id >> 6;
const constructed = (id & 0x20) !== 0;
let tag = id & 0x1f;
let i = offset + 1;
// High-tag-number form: 0b11111 says the number continues in the following
// octets, seven bits at a time. Rare, but a context tag above 30 is legal.
if (tag === 0x1f) {
tag = 0;
for (;;) {
if (i >= buf.length) throw new DerError("Truncated inside a multi-byte tag.");
const b = buf[i++]!;
tag = (tag << 7) | (b & 0x7f);
if ((b & 0x80) === 0) break;
if (tag > 0xffffff) throw new DerError("Unreasonable tag number.");
}
}
if (i >= buf.length) throw new DerError("Truncated: no length octet.");
const first = buf[i++]!;
let length: number;
if (first < 0x80) {
length = first;
} else if (first === 0x80) {
throw new DerError("Indefinite length: this is BER, and a signature must be DER.");
} else {
const n = first & 0x7f;
if (n > 4) throw new DerError("Length field too large to be real.");
if (i + n > buf.length) throw new DerError("Truncated inside a length field.");
length = 0;
for (let k = 0; k < n; k++) length = length * 256 + buf[i++]!;
}
const end = i + length;
if (end > buf.length) throw new DerError(`Truncated: a node claims ${length} bytes and only ${buf.length - i} remain.`);
return {
node: { tag, cls, constructed, content: buf.subarray(i, end), bytes: buf.subarray(offset, end) },
next: end,
};
}
/** Parse a single top-level node, refusing trailing rubbish. */
export function parse(buf: Uint8Array): Asn1 {
const { node, next } = readNode(buf, 0);
if (next !== buf.length) throw new DerError(`${buf.length - next} trailing byte(s) after the top-level value.`);
return node;
}
/** The immediate children of a constructed node. */
export function children(node: Asn1): Asn1[] {
if (!node.constructed) throw new DerError("Asked for the children of a primitive value.");
const out: Asn1[] = [];
let at = 0;
while (at < node.content.length) {
const { node: child, next } = readNode(node.content, at);
out.push(child);
at = next;
}
return out;
}
/** A child by position, checked, because "undefined" is a poor error message. */
export function at(nodes: Asn1[], index: number, what: string): Asn1 {
const n = nodes[index];
if (!n) throw new DerError(`Missing ${what}.`);
return n;
}
export function expect(node: Asn1, tag: number, what: string): Asn1 {
if (node.cls !== 0 || node.tag !== tag) throw new DerError(`Expected ${what} (universal tag ${tag}), found class ${node.cls} tag ${node.tag}.`);
return node;
}
/** A context-specific child, e.g. [0] — returns undefined when absent. */
export function contextChild(nodes: Asn1[], tag: number): Asn1 | undefined {
return nodes.find((n) => n.cls === 2 && n.tag === tag);
}
/** Dotted OID, decoded from the packed base-128 form. */
export function oid(node: Asn1): string {
expect(node, TAG.oid, "an object identifier");
const c = node.content;
if (c.length === 0) throw new DerError("Empty object identifier.");
// The first octet packs two arcs: 40*first + second.
const parts = [Math.floor(c[0]! / 40), c[0]! % 40];
let value = 0;
for (let i = 1; i < c.length; i++) {
const b = c[i]!;
value = value * 128 + (b & 0x7f);
if ((b & 0x80) === 0) {
parts.push(value);
value = 0;
}
}
return parts.join(".");
}
/** Contents of a BIT STRING, refusing the padded case nothing here should meet. */
export function bitString(node: Asn1): Uint8Array {
expect(node, TAG.bitString, "a bit string");
if (node.content.length === 0) throw new DerError("Empty bit string.");
const unused = node.content[0]!;
if (unused !== 0) throw new DerError(`Bit string with ${unused} unused bits; expected a whole number of bytes.`);
return node.content.subarray(1);
}
/** An INTEGER as a hex string, since serial numbers overflow a JS number. */
export function integerHex(node: Asn1): string {
expect(node, TAG.integer, "an integer");
let hex = "";
for (const b of node.content) hex += b.toString(16).padStart(2, "0");
return hex.replace(/^(00)+(?=.)/, "");
}
/** A small INTEGER, for versions and the like. */
export function integer(node: Asn1): number {
expect(node, TAG.integer, "an integer");
if (node.content.length > 4) throw new DerError("Integer larger than this reads.");
let v = 0;
for (const b of node.content) v = v * 256 + b;
return v;
}
/**
* UTCTime or GeneralizedTime.
*
* UTCTime carries a two-digit year, and RFC 5280 pins the window: 50-99 mean
* 1950-1999 and 00-49 mean 2000-2049. Guessing "20" + yy instead works until
* 2050 and then silently dates certificates a century early, which is the kind
* of bug that is written once and found by somebody else.
*/
export function time(node: Asn1): Date {
const s = new TextDecoder().decode(node.content);
let iso: string;
if (node.tag === TAG.utcTime) {
const m = /^(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})?Z$/.exec(s);
if (!m) throw new DerError(`Unreadable UTCTime "${s}".`);
const yy = Number(m[1]);
const year = yy >= 50 ? 1900 + yy : 2000 + yy;
iso = `${year}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6] ?? "00"}Z`;
} else if (node.tag === TAG.generalizedTime) {
const m = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})?(?:\.\d+)?Z$/.exec(s);
if (!m) throw new DerError(`Unreadable GeneralizedTime "${s}".`);
iso = `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6] ?? "00"}Z`;
} else {
throw new DerError(`Expected a time, found tag ${node.tag}.`);
}
const d = new Date(iso);
if (Number.isNaN(d.getTime())) throw new DerError(`Unreadable time "${s}".`);
return d;
}
/** Text from any of the string types a name or an address turns up in. */
export function text(node: Asn1): string {
if (node.tag === TAG.bmpString) {
// UTF-16BE. Rare, but Windows-issued certificates do use it for names.
let s = "";
for (let i = 0; i + 1 < node.content.length; i += 2) s += String.fromCharCode((node.content[i]! << 8) | node.content[i + 1]!);
return s;
}
return new TextDecoder().decode(node.content);
}
/** Lowercase hex of some bytes, for fingerprints. */
export function hex(bytes: Uint8Array): string {
let s = "";
for (const b of bytes) s += b.toString(16).padStart(2, "0");
return s;
}
+183
View File
@@ -0,0 +1,183 @@
/**
* Enough MIME to find a signed part and hand back the exact bytes it covers.
*
* This works on bytes, not on a string, and that is the whole point. A
* signature is over an octet sequence: decode it to text, re-encode it, or let
* anything normalise a line ending on the way past, and the digest changes
* while the message still looks identical on screen. Every part here keeps a
* subarray of the original buffer rather than a rebuilt copy.
*
* The one transformation that *is* applied is a lone LF becoming CRLF, and it
* is applied only to the signed part. RFC 1847 requires the protected content
* to be in canonical MIME form, which means CRLF; a store that hands back a
* message with bare LFs — and they do — would otherwise fail every signature it
* has ever held, for a reason nobody could see by looking at the message.
*/
export interface MimePart {
/** Lowercased header name to raw value, first occurrence winning. */
headers: Map<string, string>;
/** Lowercased `type/subtype`, or "text/plain" when unstated. */
contentType: string;
/** Lowercased content-type parameters. */
params: Record<string, string>;
/** The body, exactly as it appeared. */
body: Uint8Array;
/** Headers and body together, exactly as they appeared. */
raw: Uint8Array;
parts: MimePart[];
}
const CR = 13;
const LF = 10;
function indexOfSeq(hay: Uint8Array, needle: number[], from = 0): number {
outer: for (let i = from; i + needle.length <= hay.length; i++) {
for (let j = 0; j < needle.length; j++) if (hay[i + j] !== needle[j]) continue outer;
return i;
}
return -1;
}
/** Where the headers stop: the first blank line, in either line ending. */
function headerEnd(buf: Uint8Array): { bodyAt: number; headersEnd: number } {
const crlf = indexOfSeq(buf, [CR, LF, CR, LF]);
const lf = indexOfSeq(buf, [LF, LF]);
if (crlf >= 0 && (lf < 0 || crlf <= lf)) return { headersEnd: crlf, bodyAt: crlf + 4 };
if (lf >= 0) return { headersEnd: lf, bodyAt: lf + 2 };
return { headersEnd: buf.length, bodyAt: buf.length };
}
function parseHeaders(block: string): Map<string, string> {
const out = new Map<string, string>();
// Unfold first: a continuation line begins with space or tab and belongs to
// the header above it. Folding a long boundary parameter is ordinary, so a
// parser that reads line by line loses boundaries on real messages.
const unfolded = block.replace(/\r?\n[ \t]+/g, " ");
for (const line of unfolded.split(/\r?\n/)) {
const c = line.indexOf(":");
if (c <= 0) continue;
const name = line.slice(0, c).trim().toLowerCase();
if (!out.has(name)) out.set(name, line.slice(c + 1).trim());
}
return out;
}
/** Split `text/plain; charset="utf-8"` into its type and its parameters. */
export function parseContentType(value: string | undefined): { type: string; params: Record<string, string> } {
if (!value) return { type: "text/plain", params: {} };
const [head, ...rest] = value.split(";");
const params: Record<string, string> = {};
for (const p of rest) {
const eq = p.indexOf("=");
if (eq < 0) continue;
const k = p.slice(0, eq).trim().toLowerCase();
let v = p.slice(eq + 1).trim();
if (v.startsWith('"')) v = v.slice(1, v.lastIndexOf('"') > 0 ? v.lastIndexOf('"') : undefined);
params[k] = v;
}
return { type: (head ?? "").trim().toLowerCase() || "text/plain", params };
}
/** Parse a message, or a part of one, into a tree. */
export function parseMime(raw: Uint8Array): MimePart {
const { bodyAt, headersEnd } = headerEnd(raw);
const headers = parseHeaders(new TextDecoder("utf-8", { fatal: false }).decode(raw.subarray(0, headersEnd)));
const { type, params } = parseContentType(headers.get("content-type"));
const body = raw.subarray(bodyAt);
const part: MimePart = { headers, contentType: type, params, body, raw, parts: [] };
if (type.startsWith("multipart/") && params.boundary) part.parts = splitMultipart(body, params.boundary);
return part;
}
/**
* Split a multipart body on its boundary.
*
* The subtle bit is what belongs to a part and what belongs to the delimiter.
* RFC 2046 puts the CRLF *before* a boundary line into the delimiter, not into
* the part above it. Keeping that CRLF appends two bytes to the signed content
* and fails every signature; dropping one too many does the same. So each part
* ends at the byte before the CRLF that introduces the next boundary.
*/
function splitMultipart(body: Uint8Array, boundary: string): MimePart[] {
const marker = [...`--${boundary}`].map((c) => c.charCodeAt(0));
const offsets: number[] = [];
for (let i = 0; i >= 0 && i < body.length; ) {
const found = indexOfSeq(body, marker, i);
if (found < 0) break;
// Only at the start of a line.
if (found === 0 || body[found - 1] === LF) offsets.push(found);
i = found + marker.length;
}
if (offsets.length < 2) return [];
const parts: MimePart[] = [];
for (let k = 0; k < offsets.length - 1; k++) {
const delimiter = offsets[k]!;
// Step over the boundary line itself to reach the part's first header byte.
let start = delimiter + marker.length;
while (start < body.length && body[start] !== LF) start++;
start++;
// The part ends before the CRLF that belongs to the *next* delimiter.
let end = offsets[k + 1]!;
if (end > 0 && body[end - 1] === LF) end--;
if (end > 0 && body[end - 1] === CR) end--;
if (start < end) parts.push(parseMime(body.subarray(start, end)));
}
return parts;
}
/** Depth-first search for the first part matching a predicate. */
export function findPart(part: MimePart, want: (p: MimePart) => boolean): MimePart | undefined {
if (want(part)) return part;
for (const child of part.parts) {
const hit = findPart(child, want);
if (hit) return hit;
}
return undefined;
}
/**
* Canonical CRLF form, applied only to content about to be hashed.
*
* A lone LF becomes CRLF; an existing CRLF is left alone. Nothing else is
* touched -- no trailing-whitespace tidying, no re-wrapping -- because every
* other "helpful" change is one the signer did not make.
*/
export function toCanonicalCrlf(bytes: Uint8Array): Uint8Array {
let lone = 0;
for (let i = 0; i < bytes.length; i++) if (bytes[i] === LF && (i === 0 || bytes[i - 1] !== CR)) lone++;
if (lone === 0) return bytes;
const out = new Uint8Array(bytes.length + lone);
let j = 0;
for (let i = 0; i < bytes.length; i++) {
if (bytes[i] === LF && (i === 0 || bytes[i - 1] !== CR)) out[j++] = CR;
out[j++] = bytes[i]!;
}
return out;
}
/** Undo base64 or quoted-printable so a signature blob can be read as DER. */
export function decodeTransfer(part: MimePart): Uint8Array {
const encoding = (part.headers.get("content-transfer-encoding") ?? "").trim().toLowerCase();
if (encoding === "base64") {
const text = new TextDecoder().decode(part.body).replace(/[^A-Za-z0-9+/=]/g, "");
const binary = atob(text);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
return out;
}
if (encoding === "quoted-printable") {
const text = new TextDecoder().decode(part.body).replace(/=\r?\n/g, "");
const out: number[] = [];
for (let i = 0; i < text.length; i++) {
if (text[i] === "=" && i + 2 < text.length) {
out.push(parseInt(text.slice(i + 1, i + 3), 16));
i += 2;
} else out.push(text.charCodeAt(i));
}
return new Uint8Array(out);
}
return part.body;
}
+103
View File
@@ -0,0 +1,103 @@
/**
* Checking the signature on the message being read.
*
* Two things this is careful about, both about not doing work:
*
* - The verifier is imported dynamically. Signed mail is rare, and DER
* parsing plus certificate reading has no business in the bundle everybody
* downloads to read an unsigned message.
* - Nothing is fetched unless the message says it is signed. The structure
* already came with the message, so the common answer costs one string
* comparison and no network at all.
*/
import { useEffect, useState } from "react";
import { client } from "@/jmap/client";
import type { Email, EmailBodyPart, Id } from "@/jmap/types";
import { useSettings, type SignerPin } from "@/store/settings";
import type { SignatureReport } from "./verify";
/**
* How many signers are remembered before the oldest pin is dropped.
*
* A cap is needed because this rides in the account's settings file, which is
* fetched on every sign-in. Evicting is not free — a dropped signer is greeted
* as new next time, which is a quieter message than it should be — so the limit
* is set far above what S/MIME's actual prevalence will produce rather than at
* a number that trades safety for bytes.
*/
const MAX_PINS = 500;
export type SignatureState = { status: "idle" } | { status: "checking" } | { status: "done"; report: SignatureReport };
/** Whether anything in this message's structure claims to be signed. */
export function structureLooksSigned(part: EmailBodyPart | undefined): boolean {
if (!part) return false;
if (part.type === "multipart/signed") return true;
return (part.subParts ?? []).some(structureLooksSigned);
}
export function useSignature(email: Email | undefined, accountId: Id | null): SignatureState {
const [state, setState] = useState<SignatureState>({ status: "idle" });
useEffect(() => {
if (!email || !accountId || !structureLooksSigned(email.bodyStructure)) {
setState({ status: "idle" });
return;
}
let live = true;
setState({ status: "checking" });
void (async () => {
try {
const [{ judge, shouldRemember, verifyMessage }, blob] = await Promise.all([
import("./verify"),
client.fetchBlob(accountId, email.blobId, "message/rfc822"),
]);
if (!live) return;
const raw = new Uint8Array(await blob.arrayBuffer());
const from = (email.from?.[0]?.email ?? "").toLowerCase();
const crypto = await verifyMessage(raw);
const stored = useSettings.getState().settings.knownSigners[from];
// A pin this very message created is not corroboration of it. Treated
// as absent, so the message that established a signer keeps saying so
// however many times it is reopened.
const known = stored && stored.messageId === email.id ? undefined : stored;
const report = judge(crypto, from, known);
if (!live) return;
if (from && shouldRemember(report) && report.crypto.kind === "intact") {
pin(from, {
fingerprint: report.crypto.cert.fingerprint,
name: report.crypto.cert.subject.commonName || report.crypto.cert.subject.emailAddress || from,
firstSeen: new Date().toISOString(),
messageId: email.id,
});
}
setState({ status: "done", report });
} catch (err) {
if (!live) return;
// A failure to *look* is not a failure to verify, and must not be shown
// as one: a dropped connection is not a bad signature.
setState({ status: "done", report: { crypto: { kind: "unsupported", reason: "other", detail: (err as Error).message }, warnings: [] } });
}
})();
return () => {
live = false;
};
}, [email, accountId]);
return state;
}
function pin(address: string, entry: SignerPin): void {
const { settings, update } = useSettings.getState();
const next = { ...settings.knownSigners, [address]: entry };
const keys = Object.keys(next);
if (keys.length > MAX_PINS) {
const oldest = keys.sort((a, b) => (next[a]!.firstSeen < next[b]!.firstSeen ? -1 : 1)).slice(0, keys.length - MAX_PINS);
for (const k of oldest) delete next[k];
}
update({ knownSigners: next });
}
+238
View File
@@ -0,0 +1,238 @@
/**
* Checking an S/MIME signature, and deciding what may honestly be said about it.
*
* Two questions are kept deliberately apart, because conflating them is how
* signature UI becomes a lie:
*
* 1. **Did this signature verify?** Pure arithmetic. Either the bytes hash to
* what the signature says they hash to, or they do not.
* 2. **Does that mean anything?** Much weaker. The certificate travels inside
* the message, so anyone can self-sign as anyone: on its own, a verified
* signature proves only that whoever wrote the message also held the key
* in the certificate attached to it.
*
* What makes the second question worth asking at all is remembering the answer.
* The first signed message from an address pins that certificate's fingerprint;
* later ones are compared against it. That is trust on first use, and it is a
* genuinely useful thing to tell somebody -- "the same signer as every time
* before", or, much more loudly, "this is not the signer you saw before" --
* without a certificate authority anywhere in the picture.
*
* So nothing here ever renders the bare word "verified". The caller is given
* the crypto result and the trust judgement separately, and has to say both.
*/
import { parseSignedData, type SignerInfo } from "./cms";
import { decodeTransfer, findPart, parseMime, toCanonicalCrlf, type MimePart } from "./mime";
import { certCovers, parseCertificate, type Certificate } from "./x509";
/**
* Why a signature could not be checked, or did not hold.
*
* A code rather than a sentence, because the sentence has to be translated and
* this file is deliberately free of anything to do with the interface. Only
* `other` carries prose, and that prose is a parser's complaint about a
* malformed structure -- technical by nature, and shown as detail beside a
* translated headline rather than as the headline itself.
*/
export type Reason =
| "openpgp"
| "rsa-pss"
| "no-certificate"
| "not-signed-properly"
| "digest-mismatch"
| "signature-mismatch"
| "other";
/** What the signature itself established, before any question of trust. */
export type Crypto =
| { kind: "none" }
| { kind: "unsupported"; reason: Reason; detail?: string }
| { kind: "broken"; reason: Reason; detail?: string }
| { kind: "intact"; cert: Certificate; signer: SignerInfo };
/** What remembering previous signers adds to it. */
export type Trust = "first-seen" | "same-as-before" | "changed";
export type Warning = "address-mismatch" | "certificate-expired" | "certificate-not-yet-valid";
export interface KnownSigner {
fingerprint: string;
/** Who the certificate said it was, kept so a change can be described. */
name: string;
/** ISO date this fingerprint was first pinned. */
firstSeen: string;
}
export interface SignatureReport {
crypto: Crypto;
trust?: Trust;
previous?: KnownSigner;
warnings: Warning[];
}
const PKCS7_SIGNATURE = new Set(["application/pkcs7-signature", "application/x-pkcs7-signature"]);
/** Whether a raw message even claims to be signed — cheap, for deciding to look further. */
export function looksSigned(root: MimePart): boolean {
return Boolean(findPart(root, (p) => p.contentType === "multipart/signed"));
}
/**
* Verify the signature on a raw RFC822 message.
*
* Answers only the arithmetic question. Whether the certificate has anything to
* do with the sender is `judge`'s business, and keeping the two apart is what
* lets the interesting cases be tested without staging a message for each.
*/
export async function verifyMessage(raw: Uint8Array): Promise<Crypto> {
let root: MimePart;
try {
root = parseMime(raw);
} catch (err) {
return { kind: "unsupported", reason: "other", detail: (err as Error).message };
}
const signedPart = findPart(root, (p) => p.contentType === "multipart/signed");
if (!signedPart) return { kind: "none" };
if (signedPart.parts.length < 2) return { kind: "unsupported", reason: "not-signed-properly" };
const [content, signature] = signedPart.parts as [MimePart, MimePart];
if (!PKCS7_SIGNATURE.has(signature.contentType)) {
// OpenPGP lands here, and says so rather than pretending not to understand.
if (signature.contentType === "application/pgp-signature") {
return { kind: "unsupported", reason: "openpgp" };
}
return { kind: "unsupported", reason: "other", detail: signature.contentType };
}
let signed;
try {
signed = parseSignedData(decodeTransfer(signature));
} catch (err) {
return { kind: "unsupported", reason: "other", detail: (err as Error).message };
}
const signer = signed.signers[0]!;
if (signer.signature === "rsa-pss") {
// Refused rather than attempted. The salt length lives in parameters this
// does not read, and guessing it wrong fails a good signature -- which
// would be reported as "does not verify", a far worse thing to say than
// "cannot check".
return { kind: "unsupported", reason: "rsa-pss" };
}
// The signature covers the first part exactly as it arrived, headers and all,
// in canonical CRLF form.
const covered = toCanonicalCrlf(content.raw);
const digest = new Uint8Array(await crypto.subtle.digest(signer.digest, covered.slice().buffer as ArrayBuffer));
if (!sameBytes(digest, signer.messageDigest)) {
return { kind: "broken", reason: "digest-mismatch" };
}
const certs = await Promise.all(
signed.certificates.map(async (der) => {
try {
return await parseCertificate(der);
} catch {
return null;
}
}),
);
const usable = certs.filter((c): c is Certificate => c !== null);
if (usable.length === 0) return { kind: "unsupported", reason: "no-certificate" };
// Prefer the certificate the signer names, but fall back to trying each in
// turn: what settles it is which key the signature verifies under, and that
// is a stronger test than matching an issuer string.
const named = usable.find((c) => signer.issuerDer && sameBytes(c.issuerDer, signer.issuerDer) && c.serial === signer.serial);
for (const cert of named ? [named, ...usable.filter((c) => c !== named)] : usable) {
if (await signatureHolds(cert, signer)) return { kind: "intact", cert, signer };
}
return { kind: "broken", reason: "signature-mismatch" };
}
async function signatureHolds(cert: Certificate, signer: SignerInfo): Promise<boolean> {
try {
const spki = cert.spki.slice().buffer as ArrayBuffer;
const data = signer.signedAttrs.slice().buffer as ArrayBuffer;
if (cert.publicKey.kind === "rsa") {
const key = await crypto.subtle.importKey("spki", spki, { name: "RSASSA-PKCS1-v1_5", hash: signer.digest }, false, ["verify"]);
return await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, signer.value.slice().buffer as ArrayBuffer, data);
}
const key = await crypto.subtle.importKey("spki", spki, { name: "ECDSA", namedCurve: cert.publicKey.namedCurve }, false, ["verify"]);
const raw = ecdsaDerToRaw(signer.value, cert.publicKey.namedCurve);
if (!raw) return false;
return await crypto.subtle.verify({ name: "ECDSA", hash: signer.digest }, key, raw.slice().buffer as ArrayBuffer, data);
} catch {
return false;
}
}
/**
* ECDSA signatures arrive as a DER SEQUENCE of two INTEGERs; WebCrypto wants
* r and s as fixed-width bytes, concatenated. Getting the width from the curve
* rather than from the integers matters: a leading zero byte is stripped in
* DER, so r and s are frequently different lengths and neither is the answer.
*/
export function ecdsaDerToRaw(der: Uint8Array, curve: "P-256" | "P-384" | "P-521"): Uint8Array | null {
const size = curve === "P-256" ? 32 : curve === "P-384" ? 48 : 66;
try {
if (der[0] !== 0x30) return null;
let i = 2;
if (der[1]! > 0x80) i = 2 + (der[1]! & 0x7f);
const out = new Uint8Array(size * 2);
for (const slot of [0, 1]) {
if (der[i] !== 0x02) return null;
const len = der[i + 1]!;
let start = i + 2;
let n = len;
while (n > 0 && der[start] === 0x00) {
start++;
n--;
}
if (n > size) return null;
out.set(der.subarray(start, start + n), slot * size + (size - n));
i = i + 2 + len;
}
return out;
} catch {
return null;
}
}
/**
* Turn a crypto result plus what we remember into something sayable.
*
* Pure, and separate from both the network and the store, so the interesting
* cases -- a changed signer, a certificate for the wrong address -- are
* ordinary function calls to test rather than scenarios to stage.
*/
export function judge(crypto: Crypto, fromAddress: string, known: KnownSigner | undefined, now = new Date()): SignatureReport {
if (crypto.kind !== "intact") return { crypto, warnings: [] };
const warnings: Warning[] = [];
if (!certCovers(crypto.cert, fromAddress)) warnings.push("address-mismatch");
if (crypto.cert.notAfter < now) warnings.push("certificate-expired");
if (crypto.cert.notBefore > now) warnings.push("certificate-not-yet-valid");
const trust: Trust = !known ? "first-seen" : known.fingerprint === crypto.cert.fingerprint ? "same-as-before" : "changed";
return { crypto, trust, previous: trust === "changed" ? known : undefined, warnings };
}
/**
* Whether this result should be pinned as the signer for an address.
*
* Only a clean first sighting is remembered. Pinning a certificate that does
* not name the sender, or one already expired, would write the anomaly into the
* baseline and make every later message agree with it.
*/
export function shouldRemember(report: SignatureReport): boolean {
return report.crypto.kind === "intact" && report.trust === "first-seen" && report.warnings.length === 0;
}
function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!;
return diff === 0;
}
+167
View File
@@ -0,0 +1,167 @@
/**
* The parts of an X.509 certificate a signed message needs, and no more.
*
* Not a validator. Nothing here decides whether a certificate is trustworthy —
* it reads what the certificate says about itself, and what it says is only
* ever as good as whoever issued it. The trust decision lives one level up, in
* `verify.ts`, and is deliberately a small and honest one.
*/
import { at, children, DerError, expect, hex, integerHex, oid, parse, TAG, text, time, type Asn1 } from "./der";
/** Relative distinguished-name attributes worth naming. */
const OID = {
commonName: "2.5.4.3",
emailAddress: "1.2.840.113549.1.9.1",
organization: "2.5.4.10",
subjectAltName: "2.5.29.17",
rsaEncryption: "1.2.840.113549.1.1.1",
ecPublicKey: "1.2.840.10045.2.1",
curveP256: "1.2.840.10045.3.1.7",
curveP384: "1.3.132.0.34",
curveP521: "1.3.132.0.35",
} as const;
export interface Certificate {
/** SHA-256 over the whole DER, lowercase hex. What TOFU remembers. */
fingerprint: string;
serial: string;
subject: { commonName?: string; organization?: string; emailAddress?: string };
issuer: { commonName?: string; organization?: string };
/** rfc822Name entries from the subjectAltName extension, plus the subject's emailAddress. */
emails: string[];
notBefore: Date;
notAfter: Date;
/** SubjectPublicKeyInfo, DER, ready for crypto.subtle.importKey("spki", …). */
spki: Uint8Array;
publicKey: { kind: "rsa" } | { kind: "ec"; namedCurve: "P-256" | "P-384" | "P-521" };
/** Whole DER, kept so a signer can be matched and a fingerprint recomputed. */
der: Uint8Array;
/** Issuer name and serial, the pair a SignerInfo usually identifies a certificate by. */
issuerDer: Uint8Array;
}
/** Read one certificate from its DER encoding. */
export async function parseCertificate(der: Uint8Array): Promise<Certificate> {
const cert = parse(der);
const top = children(expect(cert, TAG.sequence, "a Certificate"));
const tbs = children(expect(at(top, 0, "tbsCertificate"), TAG.sequence, "a tbsCertificate"));
// tbsCertificate ::= [0] version, serial, signature, issuer, validity,
// subject, subjectPublicKeyInfo, … — version is optional and explicit, so
// everything after it shifts by one when it is absent.
let i = 0;
if (tbs[0]?.cls === 2 && tbs[0].tag === 0) i = 1;
const serial = integerHex(at(tbs, i++, "serialNumber"));
i++; // signature AlgorithmIdentifier: the outer one, not used here
const issuerNode = at(tbs, i++, "issuer");
const validity = children(expect(at(tbs, i++, "validity"), TAG.sequence, "a validity"));
const subjectNode = at(tbs, i++, "subject");
const spkiNode = at(tbs, i++, "subjectPublicKeyInfo");
const notBefore = time(at(validity, 0, "notBefore"));
const notAfter = time(at(validity, 1, "notAfter"));
const subject = readName(subjectNode);
const issuer = readName(issuerNode);
const emails = new Set<string>();
if (subject.emailAddress) emails.add(subject.emailAddress.toLowerCase());
for (const e of subjectAltEmails(tbs.slice(i))) emails.add(e.toLowerCase());
const digest = await crypto.subtle.digest("SHA-256", der.slice().buffer as ArrayBuffer);
return {
fingerprint: hex(new Uint8Array(digest)),
serial,
subject,
issuer: { commonName: issuer.commonName, organization: issuer.organization },
emails: [...emails],
notBefore,
notAfter,
spki: spkiNode.bytes,
publicKey: readKeyKind(spkiNode),
der,
issuerDer: issuerNode.bytes,
};
}
/** A Name is a sequence of RDN sets; the last occurrence of an attribute wins. */
function readName(node: Asn1): { commonName?: string; organization?: string; emailAddress?: string } {
const out: { commonName?: string; organization?: string; emailAddress?: string } = {};
for (const rdn of children(node)) {
for (const attr of children(rdn)) {
const kv = children(attr);
if (kv.length < 2) continue;
const key = oid(at(kv, 0, "an attribute type"));
const value = text(at(kv, 1, "an attribute value"));
if (key === OID.commonName) out.commonName = value;
else if (key === OID.organization) out.organization = value;
else if (key === OID.emailAddress) out.emailAddress = value;
}
}
return out;
}
/**
* rfc822Name entries from subjectAltName.
*
* This is where a modern certificate puts the address; the subject's
* emailAddress attribute is the older place and is often absent. Reading only
* one of the two means failing to match the sender on half the certificates in
* circulation.
*/
function subjectAltEmails(rest: Asn1[]): string[] {
// Extensions are [3] EXPLICIT SEQUENCE OF Extension.
const ext = rest.find((n) => n.cls === 2 && n.tag === 3);
if (!ext) return [];
const seq = children(ext)[0];
if (!seq) return [];
for (const extension of children(seq)) {
const parts = children(extension);
if (parts.length < 2) continue;
if (oid(at(parts, 0, "an extension id")) !== OID.subjectAltName) continue;
// The value is an OCTET STRING wrapping the real structure. Critical flag
// may sit between the two, so take the last part rather than index 1.
const wrapper = parts[parts.length - 1]!;
try {
const names = children(parse(wrapper.content));
// GeneralName ::= CHOICE, and rfc822Name is [1] IMPLICIT IA5String.
return names.filter((n) => n.cls === 2 && n.tag === 1).map((n) => new TextDecoder().decode(n.content));
} catch {
return [];
}
}
return [];
}
function readKeyKind(spki: Asn1): Certificate["publicKey"] {
const parts = children(spki);
const alg = children(at(parts, 0, "an algorithm identifier"));
const algOid = oid(at(alg, 0, "an algorithm"));
if (algOid === OID.rsaEncryption) return { kind: "rsa" };
if (algOid === OID.ecPublicKey) {
const curve = alg[1] ? oid(alg[1]) : "";
if (curve === OID.curveP256) return { kind: "ec", namedCurve: "P-256" };
if (curve === OID.curveP384) return { kind: "ec", namedCurve: "P-384" };
if (curve === OID.curveP521) return { kind: "ec", namedCurve: "P-521" };
throw new DerError(`Unsupported elliptic curve ${curve}.`);
}
throw new DerError(`Unsupported public key algorithm ${algOid}.`);
}
/** Whether the certificate names this address, case-insensitively. */
export function certCovers(cert: Certificate, address: string): boolean {
const a = address.trim().toLowerCase();
return cert.emails.includes(a);
}
/** A short, readable name for the human holding the certificate. */
export function certDisplayName(cert: Certificate): string {
return cert.subject.commonName || cert.subject.emailAddress || cert.emails[0] || cert.subject.organization || cert.serial;
}
/** The fingerprint in the grouped form people actually compare by eye. */
export function formatFingerprint(fp: string): string {
return (fp.match(/.{2}/g) ?? [fp]).join(":").toUpperCase();
}
+35
View File
@@ -1331,6 +1331,41 @@ export const catalog: Catalog = {
"Empty “{name}”?": "„{name}“ leeren?",
"Delete them": "Alle löschen",
"Nothing was deleted": "Es wurde nichts gelöscht",
// ── Checking an S/MIME signature, and what may be said about it ──
"Certificate covers": "Zertifikat gilt für",
"Details": "Details",
"Earlier messages from this address were signed by {previous}. This one is signed by {current}.": "Frühere Nachrichten von dieser Adresse wurden von {previous} signiert. Diese ist von {current} signiert.",
"Fingerprint": "Fingerabdruck",
"Hide details": "Details ausblenden",
"Issued by": "Ausgestellt von",
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "Sie ist mit OpenPGP signiert, und ihasmail hat keine Möglichkeit, den öffentlichen Schlüssel des Absenders zu beschaffen.",
"It uses a signature algorithm ihasmail cannot check yet.": "Sie verwendet ein Signaturverfahren, das ihasmail noch nicht prüfen kann.",
"It was made with a certificate belonging to {name}, which does not cover this address.": "Sie wurde mit einem Zertifikat von {name} erstellt, das diese Adresse nicht abdeckt.",
"Previous fingerprint": "Vorheriger Fingerabdruck",
"Signed at": "Signiert am",
"Signed by {name} — the same signer as before.": "Signiert von {name} — derselbe Unterzeichner wie zuvor.",
"Signed by {name}, seen here for the first time.": "Signiert von {name}, hier zum ersten Mal gesehen.",
"Signer": "Unterzeichner",
"That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.": "Das kann ein erneuertes Zertifikat bedeuten, und es kann jemand anderes sein. Fragen Sie auf einem anderen Weg nach, bevor Sie dem vertrauen.",
"The certificate has expired.": "Das Zertifikat ist abgelaufen.",
"The certificate is not valid yet.": "Das Zertifikat ist noch nicht gültig.",
"The message does not match what was signed — it was altered after signing, or damaged on the way.": "Die Nachricht stimmt nicht mit dem Signierten überein — sie wurde nach dem Signieren verändert oder unterwegs beschädigt.",
"The signature carries no certificate that can be read.": "Die Signatur enthält kein lesbares Zertifikat.",
"The signature could not be read.": "Die Signatur konnte nicht gelesen werden.",
"The signature does not match the certificate sent with it.": "Die Signatur passt nicht zum mitgesendeten Zertifikat.",
"The signature is not for this sender.": "Die Signatur gehört nicht zu diesem Absender.",
"The signed part is missing either the message or the signature.": "Im signierten Teil fehlt entweder die Nachricht oder die Signatur.",
"The signer has changed.": "Der Unterzeichner hat gewechselt.",
"This message is signed, and ihasmail could not check the signature.": "Diese Nachricht ist signiert, und ihasmail konnte die Signatur nicht prüfen.",
"This signature does not check out.": "Diese Signatur stimmt nicht.",
"Valid until": "Gültig bis",
"a different certificate": "einem anderen Zertifikat",
"an unnamed signer": "einem unbenannten Unterzeichner",
"as claimed by the signer": "laut Angabe des Unterzeichners",
"first seen {date}": "zuerst gesehen {date}",
"ihasmail will tell you if a later message from this address is signed by anybody else.": "ihasmail weist Sie darauf hin, wenn eine spätere Nachricht von dieser Adresse von jemand anderem signiert ist.",
"itself, or an issuer it does not name": "sich selbst, oder einem nicht genannten Aussteller",
"no address": "keine Adresse",
},
plurals: {
// ── Third pass ─────────────────────────────────────────────────────
+35
View File
@@ -1304,6 +1304,41 @@ export const catalog: Catalog = {
"Empty “{name}”?": "¿Vaciar «{name}»?",
"Delete them": "Eliminarlos",
"Nothing was deleted": "No se ha eliminado nada",
// ── Checking an S/MIME signature, and what may be said about it ──
"Certificate covers": "El certificado cubre",
"Details": "Detalles",
"Earlier messages from this address were signed by {previous}. This one is signed by {current}.": "Los mensajes anteriores de esta dirección los firmaba {previous}. Este lo firma {current}.",
"Fingerprint": "Huella digital",
"Hide details": "Ocultar detalles",
"Issued by": "Emitido por",
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "Está firmado con OpenPGP, y ihasmail no tiene forma de obtener la clave pública del remitente.",
"It uses a signature algorithm ihasmail cannot check yet.": "Usa un algoritmo de firma que ihasmail todavía no puede comprobar.",
"It was made with a certificate belonging to {name}, which does not cover this address.": "Se hizo con un certificado de {name}, que no cubre esta dirección.",
"Previous fingerprint": "Huella digital anterior",
"Signed at": "Firmado el",
"Signed by {name} — the same signer as before.": "Firmado por {name}: el mismo firmante que antes.",
"Signed by {name}, seen here for the first time.": "Firmado por {name}, visto aquí por primera vez.",
"Signer": "Firmante",
"That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.": "Puede tratarse de un certificado renovado, y puede tratarse de otra persona. Compruébelo con ella por otra vía antes de confiar.",
"The certificate has expired.": "El certificado ha caducado.",
"The certificate is not valid yet.": "El certificado aún no es válido.",
"The message does not match what was signed — it was altered after signing, or damaged on the way.": "El mensaje no coincide con lo que se firmó: se alteró después de firmarlo, o se dañó por el camino.",
"The signature carries no certificate that can be read.": "La firma no lleva ningún certificado que se pueda leer.",
"The signature could not be read.": "No se ha podido leer la firma.",
"The signature does not match the certificate sent with it.": "La firma no coincide con el certificado enviado con ella.",
"The signature is not for this sender.": "La firma no corresponde a este remitente.",
"The signed part is missing either the message or the signature.": "A la parte firmada le falta el mensaje o la firma.",
"The signer has changed.": "El firmante ha cambiado.",
"This message is signed, and ihasmail could not check the signature.": "Este mensaje está firmado, y ihasmail no ha podido comprobar la firma.",
"This signature does not check out.": "Esta firma no cuadra.",
"Valid until": "Válido hasta",
"a different certificate": "un certificado distinto",
"an unnamed signer": "un firmante sin nombre",
"as claimed by the signer": "según declara el firmante",
"first seen {date}": "visto por primera vez el {date}",
"ihasmail will tell you if a later message from this address is signed by anybody else.": "ihasmail le avisará si un mensaje posterior de esta dirección lo firma otra persona.",
"itself, or an issuer it does not name": "sí mismo, o un emisor que no nombra",
"no address": "ninguna dirección",
},
plurals: {
// ── Third pass ─────────────────────────────────────────────────────
+35
View File
@@ -1309,6 +1309,41 @@ export const catalog: Catalog = {
"Empty “{name}”?": "Vider « {name} » ?",
"Delete them": "Les supprimer",
"Nothing was deleted": "Rien na été supprimé",
// ── Checking an S/MIME signature, and what may be said about it ──
"Certificate covers": "Le certificat couvre",
"Details": "Détails",
"Earlier messages from this address were signed by {previous}. This one is signed by {current}.": "Les messages précédents de cette adresse étaient signés par {previous}. Celui-ci est signé par {current}.",
"Fingerprint": "Empreinte",
"Hide details": "Masquer les détails",
"Issued by": "Délivré par",
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "Il est signé avec OpenPGP, et ihasmail n'a aucun moyen de récupérer la clé publique de l'expéditeur.",
"It uses a signature algorithm ihasmail cannot check yet.": "Il utilise un algorithme de signature qu'ihasmail ne sait pas encore vérifier.",
"It was made with a certificate belonging to {name}, which does not cover this address.": "Elle a été faite avec un certificat appartenant à {name}, qui ne couvre pas cette adresse.",
"Previous fingerprint": "Empreinte précédente",
"Signed at": "Signé le",
"Signed by {name} — the same signer as before.": "Signé par {name} — le même signataire que précédemment.",
"Signed by {name}, seen here for the first time.": "Signé par {name}, vu ici pour la première fois.",
"Signer": "Signataire",
"That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.": "Cela peut signifier un certificat renouvelé, comme cela peut être quelqu'un d'autre. Vérifiez avec la personne par un autre moyen avant de faire confiance.",
"The certificate has expired.": "Le certificat a expiré.",
"The certificate is not valid yet.": "Le certificat n'est pas encore valide.",
"The message does not match what was signed — it was altered after signing, or damaged on the way.": "Le message ne correspond pas à ce qui a été signé : il a été modifié après signature, ou abîmé en chemin.",
"The signature carries no certificate that can be read.": "La signature ne contient aucun certificat lisible.",
"The signature could not be read.": "La signature n'a pas pu être lue.",
"The signature does not match the certificate sent with it.": "La signature ne correspond pas au certificat envoyé avec elle.",
"The signature is not for this sender.": "La signature ne correspond pas à cet expéditeur.",
"The signed part is missing either the message or the signature.": "Il manque à la partie signée soit le message, soit la signature.",
"The signer has changed.": "Le signataire a changé.",
"This message is signed, and ihasmail could not check the signature.": "Ce message est signé, et ihasmail n'a pas pu vérifier la signature.",
"This signature does not check out.": "Cette signature ne tient pas.",
"Valid until": "Valable jusqu'au",
"a different certificate": "un certificat différent",
"an unnamed signer": "un signataire sans nom",
"as claimed by the signer": "selon le signataire",
"first seen {date}": "vu pour la première fois le {date}",
"ihasmail will tell you if a later message from this address is signed by anybody else.": "ihasmail vous préviendra si un message ultérieur de cette adresse est signé par quelqu'un d'autre.",
"itself, or an issuer it does not name": "lui-même, ou un émetteur qu'il ne nomme pas",
"no address": "aucune adresse",
},
plurals: {
// ── Third pass ─────────────────────────────────────────────────────
+35
View File
@@ -1312,6 +1312,41 @@ export const catalog: Catalog = {
"Empty “{name}”?": "「{name}」を空にしますか?",
"Delete them": "削除する",
"Nothing was deleted": "何も削除されませんでした",
// ── Checking an S/MIME signature, and what may be said about it ──
"Certificate covers": "証明書の対象",
"Details": "詳細",
"Earlier messages from this address were signed by {previous}. This one is signed by {current}.": "このアドレスからの以前のメールは {previous} が署名していました。このメールの署名者は {current} です。",
"Fingerprint": "フィンガープリント",
"Hide details": "詳細を隠す",
"Issued by": "発行者",
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "OpenPGP で署名されており、ihasmail には送信者の公開鍵を取得する手段がありません。",
"It uses a signature algorithm ihasmail cannot check yet.": "ihasmail がまだ検証できない署名アルゴリズムが使われています。",
"It was made with a certificate belonging to {name}, which does not cover this address.": "{name} の証明書で署名されており、この証明書はこのアドレスを対象にしていません。",
"Previous fingerprint": "以前のフィンガープリント",
"Signed at": "署名日時",
"Signed by {name} — the same signer as before.": "{name} による署名です。以前と同じ署名者です。",
"Signed by {name}, seen here for the first time.": "{name} による署名です。ここで見るのは初めてです。",
"Signer": "署名者",
"That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.": "証明書を更新しただけの場合もあれば、別人の場合もあります。信頼する前に、別の手段で本人に確認してください。",
"The certificate has expired.": "証明書の有効期限が切れています。",
"The certificate is not valid yet.": "証明書はまだ有効ではありません。",
"The message does not match what was signed — it was altered after signing, or damaged on the way.": "メールが署名された内容と一致しません。署名後に変更されたか、途中で壊れています。",
"The signature carries no certificate that can be read.": "署名に読み取れる証明書が含まれていません。",
"The signature could not be read.": "署名を読み取れませんでした。",
"The signature does not match the certificate sent with it.": "署名が、一緒に送られた証明書と一致しません。",
"The signature is not for this sender.": "この署名はこの送信者のものではありません。",
"The signed part is missing either the message or the signature.": "署名された部分に、本文か署名のどちらかが欠けています。",
"The signer has changed.": "署名者が変わりました。",
"This message is signed, and ihasmail could not check the signature.": "このメールには署名がありますが、ihasmail は署名を検証できませんでした。",
"This signature does not check out.": "この署名は正しくありません。",
"Valid until": "有効期限",
"a different certificate": "別の証明書",
"an unnamed signer": "名前のない署名者",
"as claimed by the signer": "署名者の申告による",
"first seen {date}": "初回は {date}",
"ihasmail will tell you if a later message from this address is signed by anybody else.": "このアドレスからの以降のメールが別の人の署名だった場合、ihasmail がお知らせします。",
"itself, or an issuer it does not name": "自分自身、または名前のない発行者",
"no address": "アドレスなし",
},
plurals: {
// ── Third pass ─────────────────────────────────────────────────────
+35
View File
@@ -1300,6 +1300,41 @@ export const catalog: Catalog = {
"Empty “{name}”?": "„{name}” leegmaken?",
"Delete them": "Verwijderen",
"Nothing was deleted": "Er is niets verwijderd",
// ── Checking an S/MIME signature, and what may be said about it ──
"Certificate covers": "Certificaat geldt voor",
"Details": "Details",
"Earlier messages from this address were signed by {previous}. This one is signed by {current}.": "Eerdere berichten van dit adres waren ondertekend door {previous}. Dit bericht is ondertekend door {current}.",
"Fingerprint": "Vingerafdruk",
"Hide details": "Details verbergen",
"Issued by": "Uitgegeven door",
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "Het is ondertekend met OpenPGP, en ihasmail kan de openbare sleutel van de afzender niet ophalen.",
"It uses a signature algorithm ihasmail cannot check yet.": "Het gebruikt een ondertekeningsalgoritme dat ihasmail nog niet kan controleren.",
"It was made with a certificate belonging to {name}, which does not cover this address.": "Hij is gemaakt met een certificaat van {name}, dat dit adres niet dekt.",
"Previous fingerprint": "Vorige vingerafdruk",
"Signed at": "Ondertekend op",
"Signed by {name} — the same signer as before.": "Ondertekend door {name} — dezelfde ondertekenaar als eerder.",
"Signed by {name}, seen here for the first time.": "Ondertekend door {name}, hier voor het eerst gezien.",
"Signer": "Ondertekenaar",
"That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.": "Dat kan een vernieuwd certificaat betekenen, en het kan iemand anders zijn. Vraag het langs een andere weg na voordat u erop vertrouwt.",
"The certificate has expired.": "Het certificaat is verlopen.",
"The certificate is not valid yet.": "Het certificaat is nog niet geldig.",
"The message does not match what was signed — it was altered after signing, or damaged on the way.": "Het bericht komt niet overeen met wat er is ondertekend — het is na ondertekening gewijzigd, of onderweg beschadigd.",
"The signature carries no certificate that can be read.": "De handtekening bevat geen leesbaar certificaat.",
"The signature could not be read.": "De handtekening kon niet worden gelezen.",
"The signature does not match the certificate sent with it.": "De handtekening hoort niet bij het meegestuurde certificaat.",
"The signature is not for this sender.": "De handtekening is niet van deze afzender.",
"The signed part is missing either the message or the signature.": "In het ondertekende deel ontbreekt het bericht of de handtekening.",
"The signer has changed.": "De ondertekenaar is veranderd.",
"This message is signed, and ihasmail could not check the signature.": "Dit bericht is ondertekend, en ihasmail kon de handtekening niet controleren.",
"This signature does not check out.": "Deze handtekening klopt niet.",
"Valid until": "Geldig tot",
"a different certificate": "een ander certificaat",
"an unnamed signer": "een naamloze ondertekenaar",
"as claimed by the signer": "volgens de ondertekenaar",
"first seen {date}": "voor het eerst gezien op {date}",
"ihasmail will tell you if a later message from this address is signed by anybody else.": "ihasmail laat het weten als een later bericht van dit adres door iemand anders is ondertekend.",
"itself, or an issuer it does not name": "zichzelf, of een uitgever die het niet noemt",
"no address": "geen adres",
},
plurals: {
// ── Third pass ─────────────────────────────────────────────────────
+35
View File
@@ -1307,6 +1307,41 @@ export const catalog: Catalog = {
"Empty “{name}”?": "Esvaziar “{name}”?",
"Delete them": "Excluir todos",
"Nothing was deleted": "Nada foi excluído",
// ── Checking an S/MIME signature, and what may be said about it ──
"Certificate covers": "O certificado cobre",
"Details": "Detalhes",
"Earlier messages from this address were signed by {previous}. This one is signed by {current}.": "As mensagens anteriores deste endereço eram assinadas por {previous}. Esta é assinada por {current}.",
"Fingerprint": "Impressão digital",
"Hide details": "Ocultar detalhes",
"Issued by": "Emitido por",
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "Está assinada com OpenPGP, e o ihasmail não tem como obter a chave pública do remetente.",
"It uses a signature algorithm ihasmail cannot check yet.": "Usa um algoritmo de assinatura que o ihasmail ainda não consegue conferir.",
"It was made with a certificate belonging to {name}, which does not cover this address.": "Foi feita com um certificado de {name}, que não cobre este endereço.",
"Previous fingerprint": "Impressão digital anterior",
"Signed at": "Assinado em",
"Signed by {name} — the same signer as before.": "Assinado por {name} — o mesmo signatário de antes.",
"Signed by {name}, seen here for the first time.": "Assinado por {name}, visto aqui pela primeira vez.",
"Signer": "Signatário",
"That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.": "Isso pode ser um certificado renovado, e pode ser outra pessoa. Confirme com ela por outro caminho antes de confiar.",
"The certificate has expired.": "O certificado expirou.",
"The certificate is not valid yet.": "O certificado ainda não é válido.",
"The message does not match what was signed — it was altered after signing, or damaged on the way.": "A mensagem não corresponde ao que foi assinado — ela foi alterada depois da assinatura, ou danificada no caminho.",
"The signature carries no certificate that can be read.": "A assinatura não traz nenhum certificado que possa ser lido.",
"The signature could not be read.": "Não foi possível ler a assinatura.",
"The signature does not match the certificate sent with it.": "A assinatura não corresponde ao certificado enviado com ela.",
"The signature is not for this sender.": "A assinatura não é deste remetente.",
"The signed part is missing either the message or the signature.": "Falta à parte assinada ou a mensagem ou a assinatura.",
"The signer has changed.": "O signatário mudou.",
"This message is signed, and ihasmail could not check the signature.": "Esta mensagem está assinada, e o ihasmail não conseguiu conferir a assinatura.",
"This signature does not check out.": "Esta assinatura não confere.",
"Valid until": "Válido até",
"a different certificate": "um certificado diferente",
"an unnamed signer": "um signatário sem nome",
"as claimed by the signer": "conforme declarado pelo signatário",
"first seen {date}": "visto pela primeira vez em {date}",
"ihasmail will tell you if a later message from this address is signed by anybody else.": "O ihasmail avisará você se uma mensagem posterior deste endereço for assinada por outra pessoa.",
"itself, or an issuer it does not name": "ele mesmo, ou um emissor que ele não nomeia",
"no address": "nenhum endereço",
},
plurals: {
// ── Third pass ─────────────────────────────────────────────────────
+35
View File
@@ -1306,6 +1306,41 @@ export const catalog: Catalog = {
"Empty “{name}”?": "Очистить «{name}»?",
"Delete them": "Удалить их",
"Nothing was deleted": "Ничего не удалено",
// ── Checking an S/MIME signature, and what may be said about it ──
"Certificate covers": "Сертификат распространяется на",
"Details": "Подробности",
"Earlier messages from this address were signed by {previous}. This one is signed by {current}.": "Прежние письма с этого адреса подписывал {previous}. Это подписано {current}.",
"Fingerprint": "Отпечаток",
"Hide details": "Скрыть подробности",
"Issued by": "Кем выдан",
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "Письмо подписано OpenPGP, а ihasmail не может получить открытый ключ отправителя.",
"It uses a signature algorithm ihasmail cannot check yet.": "Использован алгоритм подписи, который ihasmail пока не умеет проверять.",
"It was made with a certificate belonging to {name}, which does not cover this address.": "Подпись сделана сертификатом, принадлежащим {name}, который не покрывает этот адрес.",
"Previous fingerprint": "Прежний отпечаток",
"Signed at": "Подписано",
"Signed by {name} — the same signer as before.": "Подписано {name} — тот же подписавший, что и раньше.",
"Signed by {name}, seen here for the first time.": "Подписано {name}, встречается здесь впервые.",
"Signer": "Подписавший",
"That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.": "Это может быть обновлённый сертификат, а может быть и другой человек. Прежде чем доверять, свяжитесь с отправителем другим способом.",
"The certificate has expired.": "Срок действия сертификата истёк.",
"The certificate is not valid yet.": "Сертификат ещё не действителен.",
"The message does not match what was signed — it was altered after signing, or damaged on the way.": "Письмо не совпадает с тем, что было подписано — его изменили после подписания или повредили в пути.",
"The signature carries no certificate that can be read.": "В подписи нет сертификата, который удалось бы прочитать.",
"The signature could not be read.": "Подпись не удалось прочитать.",
"The signature does not match the certificate sent with it.": "Подпись не соответствует приложенному сертификату.",
"The signature is not for this sender.": "Подпись не принадлежит этому отправителю.",
"The signed part is missing either the message or the signature.": "В подписанной части не хватает либо письма, либо подписи.",
"The signer has changed.": "Подписавший изменился.",
"This message is signed, and ihasmail could not check the signature.": "Это письмо подписано, и ihasmail не смог проверить подпись.",
"This signature does not check out.": "Эта подпись не сходится.",
"Valid until": "Действует до",
"a different certificate": "другим сертификатом",
"an unnamed signer": "неназванным подписавшим",
"as claimed by the signer": "по словам подписавшего",
"first seen {date}": "впервые замечен {date}",
"ihasmail will tell you if a later message from this address is signed by anybody else.": "ihasmail сообщит, если следующее письмо с этого адреса подпишет кто-то другой.",
"itself, or an issuer it does not name": "самим собой или неназванным издателем",
"no address": "нет адреса",
},
plurals: {
// ── Third pass ─────────────────────────────────────────────────────
+35
View File
@@ -1300,6 +1300,41 @@ export const catalog: Catalog = {
"Empty “{name}”?": "Очистити «{name}»?",
"Delete them": "Видалити їх",
"Nothing was deleted": "Нічого не видалено",
// ── Checking an S/MIME signature, and what may be said about it ──
"Certificate covers": "Сертифікат поширюється на",
"Details": "Подробиці",
"Earlier messages from this address were signed by {previous}. This one is signed by {current}.": "Попередні листи з цієї адреси підписував {previous}. Цей підписаний {current}.",
"Fingerprint": "Відбиток",
"Hide details": "Сховати подробиці",
"Issued by": "Ким видано",
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "Лист підписано OpenPGP, а ihasmail не може отримати відкритий ключ відправника.",
"It uses a signature algorithm ihasmail cannot check yet.": "Використано алгоритм підпису, який ihasmail поки не вміє перевіряти.",
"It was made with a certificate belonging to {name}, which does not cover this address.": "Підпис зроблено сертифікатом, що належить {name} і не покриває цю адресу.",
"Previous fingerprint": "Попередній відбиток",
"Signed at": "Підписано",
"Signed by {name} — the same signer as before.": "Підписано {name} — той самий підписувач, що й раніше.",
"Signed by {name}, seen here for the first time.": "Підписано {name}, трапляється тут уперше.",
"Signer": "Підписувач",
"That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.": "Це може бути оновлений сертифікат, а може бути й інша людина. Перш ніж довіряти, зв'яжіться з відправником іншим шляхом.",
"The certificate has expired.": "Термін дії сертифіката минув.",
"The certificate is not valid yet.": "Сертифікат ще не чинний.",
"The message does not match what was signed — it was altered after signing, or damaged on the way.": "Лист не збігається з тим, що було підписано — його змінили після підписання або пошкодили в дорозі.",
"The signature carries no certificate that can be read.": "У підписі немає сертифіката, який вдалося б прочитати.",
"The signature could not be read.": "Підпис не вдалося прочитати.",
"The signature does not match the certificate sent with it.": "Підпис не відповідає надісланому з ним сертифікату.",
"The signature is not for this sender.": "Підпис не належить цьому відправникові.",
"The signed part is missing either the message or the signature.": "У підписаній частині бракує або листа, або підпису.",
"The signer has changed.": "Підписувач змінився.",
"This message is signed, and ihasmail could not check the signature.": "Цей лист підписано, і ihasmail не зміг перевірити підпис.",
"This signature does not check out.": "Цей підпис не сходиться.",
"Valid until": "Чинний до",
"a different certificate": "іншим сертифікатом",
"an unnamed signer": "неназваним підписувачем",
"as claimed by the signer": "за словами підписувача",
"first seen {date}": "уперше побачено {date}",
"ihasmail will tell you if a later message from this address is signed by anybody else.": "ihasmail повідомить, якщо наступний лист із цієї адреси підпише хтось інший.",
"itself, or an issuer it does not name": "самим собою або неназваним видавцем",
"no address": "немає адреси",
},
plurals: {
// ── Third pass ─────────────────────────────────────────────────────
+35
View File
@@ -1311,6 +1311,41 @@ export const catalog: Catalog = {
"Empty “{name}”?": "清空“{name}”?",
"Delete them": "删除",
"Nothing was deleted": "未删除任何内容",
// ── Checking an S/MIME signature, and what may be said about it ──
"Certificate covers": "证书适用于",
"Details": "详情",
"Earlier messages from this address were signed by {previous}. This one is signed by {current}.": "此地址以前的邮件由 {previous} 签名,而这封由 {current} 签名。",
"Fingerprint": "指纹",
"Hide details": "隐藏详情",
"Issued by": "颁发者",
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "该邮件使用 OpenPGP 签名,而 ihasmail 无法获取发件人的公钥。",
"It uses a signature algorithm ihasmail cannot check yet.": "它使用了 ihasmail 尚不能校验的签名算法。",
"It was made with a certificate belonging to {name}, which does not cover this address.": "签名使用的是 {name} 的证书,该证书并不包含此地址。",
"Previous fingerprint": "以前的指纹",
"Signed at": "签名时间",
"Signed by {name} — the same signer as before.": "由 {name} 签名——与此前是同一签名者。",
"Signed by {name}, seen here for the first time.": "由 {name} 签名,这是首次在此见到。",
"Signer": "签名者",
"That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.": "这可能只是更换了证书,也可能是另一个人。信任之前,请通过其他途径向对方核实。",
"The certificate has expired.": "证书已过期。",
"The certificate is not valid yet.": "证书尚未生效。",
"The message does not match what was signed — it was altered after signing, or damaged on the way.": "邮件与所签名的内容不一致——它在签名后被改动,或在传输中损坏。",
"The signature carries no certificate that can be read.": "签名中没有可读取的证书。",
"The signature could not be read.": "无法读取签名。",
"The signature does not match the certificate sent with it.": "签名与随附的证书不匹配。",
"The signature is not for this sender.": "该签名不属于此发件人。",
"The signed part is missing either the message or the signature.": "签名部分缺少邮件正文或签名。",
"The signer has changed.": "签名者已更换。",
"This message is signed, and ihasmail could not check the signature.": "此邮件带有签名,但 ihasmail 无法校验该签名。",
"This signature does not check out.": "此签名不成立。",
"Valid until": "有效期至",
"a different certificate": "另一份证书",
"an unnamed signer": "未具名的签名者",
"as claimed by the signer": "据签名者声称",
"first seen {date}": "首次见于 {date}",
"ihasmail will tell you if a later message from this address is signed by anybody else.": "如果此地址之后的邮件由他人签名,ihasmail 会提醒您。",
"itself, or an issuer it does not name": "其自身,或一个未具名的颁发者",
"no address": "无地址",
},
plurals: {
// ── Third pass ─────────────────────────────────────────────────────
+37
View File
@@ -57,6 +57,25 @@ export interface Template {
html: string;
}
/** A signer remembered for an address. See `knownSigners`. */
export interface SignerPin {
/** SHA-256 of the certificate's DER, lowercase hex. */
fingerprint: string;
/** What the certificate called its holder, so a change can be described. */
name: string;
/** When this fingerprint was first pinned, ISO 8601. */
firstSeen: string;
/**
* The message that established the pin.
*
* Without this, the message that *created* a pin reads as corroborated by it
* the next time it is opened — "the same signer as before", where before is
* itself. That is a claim of corroboration from evidence that does not
* exist, and it appears on the very first signed message somebody receives.
*/
messageId?: string;
}
export interface Settings {
/**
* Kept, and kept correct, for a device still running a build that only knows
@@ -91,6 +110,23 @@ export interface Settings {
* already follow the reader between devices.
*/
addedShares: string[];
/**
* S/MIME signers pinned on first sight, keyed by lowercased address.
*
* This is the whole trust model for signature checking, and it is a small
* one: a browser has no system trust store, and the certificate that signs a
* message travels inside it, so "this signature verifies" on its own says
* only that the sender held the key they attached. What makes it worth
* anything is remembering — the same signer as last time is reassuring, and a
* different one is worth interrupting somebody over.
*
* It lives in the synced settings rather than in this browser because a pin
* that only one device knows about would greet the same correspondent as new
* on every other one, which trains people to click past exactly the warning
* this exists to raise.
*/
knownSigners: Record<string, SignerPin>;
imagePolicy: ImagePolicy;
/** Let messages follow the app's light/dark theme instead of always sitting on white. */
themeMessageBody: boolean;
@@ -268,6 +304,7 @@ export const DEFAULT_SETTINGS: Settings = {
pageSize: 50,
markReadDelay: 0,
addedShares: [],
knownSigners: {},
imagePolicy: "ask",
themeMessageBody: false,
undoSendSeconds: 8,
+14
View File
@@ -1781,6 +1781,20 @@ button.dp-open:disabled { cursor: default; opacity: .5; }
/* The banner naming a sender from outside the organisation. */
.remote-banner.external-banner { background: var(--warn-soft); border-color: var(--warn); }
/*
* The signature banner's colour is the claim it is making, so the quiet tones
* are deliberate. "Signed by somebody new" is grey, because an unknown
* certificate that verifies against itself has established nothing worth a
* green tick; green is kept for the one case that earned it, a signer matching
* what was pinned the first time. Red is for a signer that changed.
*/
.remote-banner.signature-banner { align-items: flex-start; }
.remote-banner.signature-banner.good { background: var(--success-soft); color: var(--success); }
.remote-banner.signature-banner.warn { background: var(--warn-soft); color: var(--warn); }
.remote-banner.signature-banner.danger { background: var(--danger-soft); color: var(--danger); }
.remote-banner.signature-banner.quiet { background: var(--bg-sunken); color: var(--fg-muted); }
.remote-banner.signature-banner .sessions-table td { padding: 2px 8px 2px 0; }
.remote-banner.signature-banner .hint { opacity: .8; }
/* The line offering the whole folder once the page is selected. */
.list-hint.select-all-hint { background: var(--accent-soft); color: var(--accent-soft-fg); }
+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");
});
});