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 judgment 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;
|
|
}
|