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.
239 lines
9.7 KiB
TypeScript
239 lines
9.7 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|