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