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();
}