fix(smime): certificate address binding prefers the deprecated DN attribute
Finding 11, found while writing the EJBCA runbook rather than from a test -
and it is a blocker that fix 1 created.
extractEmailAddresses collected the Subject DN emailAddress attribute
(OID 1.2.840.113549.1.9.1) BEFORE the SAN rfc822Name, and every consumer
reads emailAddresses[0]. Under RFC 5280/8550 the SAN is authoritative and
the DN attribute is legacy, retained only for old clients - so the order
was exactly backwards. Compounding it, signerEmailMatch compared the From
header against position 0 only, never against the other addresses a
certificate legitimately carries.
Two ways a perfectly valid certificate failed:
1. DN and SAN disagree in any respect - case, domain form, a stale
value. The DN wins, From never matches.
2. A multi-alias certificate where the message was sent From the
SECOND rfc822Name. Only [0] is compared, so it mismatches.
Before fix 1 that was a cosmetic amber "signer != From" banner. After fix
1 it BLOCKS auto-import, so the correspondent's encryption certificate is
never stored and encryption silently never becomes available for them.
I turned a latent wart into a functional blocker in the same audit.
This was not hypothetical for much longer: EJBCA populates both fields by
default once the end-entity profile has an email field, which is exactly
what the CA runbook configures. The internal CA would have shipped
certificates this client mishandles on day one.
Fix:
- collect SAN rfc822Name first, DN emailAddress second, de-duplicated
case-insensitively, so [0] is the authoritative address
- add certAssertsAddress(), matching against every address the
certificate asserts rather than only the first
- file the signer certificate under the address the message actually came
from when the certificate asserts it. That address is the key used for
encryption lookups later, so storing a usable certificate under a
different one of its addresses hides it from the code that needs it.
The manual-import paths (index.js:961, pkcs12.js:114) have no From header
to match against and are corrected by the reordering alone.
Verified: new verify-address-binding.mjs, 18 assertions, self-contained -
it generates its own certificates with openssl, including one whose SAN
and DN deliberately disagree, and asserts openssl really emitted both
forms before drawing any conclusion.
Confirmed the bug was real rather than assumed, by running the same suite
against the pre-fix file restored from git with the old [0]-only matching
shimmed back in: emailAddresses[0] resolves to legacy.address@old.example
and all three match assertions fail. Every REFUSAL case still passed both
before and after, so this removes false negatives without loosening the
gate - lookalike domains, substrings and empty addresses are still
refused.
51 + 28 + 18 = 97 assertions passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
fe77e9f52b
commit
fb40e74713
@@ -131,14 +131,23 @@ function extractExtendedKeyUsage(cert) {
|
||||
return ext.parsedValue.keyPurposes;
|
||||
}
|
||||
|
||||
// Order is load-bearing, not cosmetic. Callers identify a certificate by
|
||||
// `emailAddresses[0]` — it becomes the storage key an encryption certificate is
|
||||
// filed under, and the address `signerEmailMatch` compares against the `From`
|
||||
// header. Under RFC 5280 / 8550 the authoritative address is the SAN
|
||||
// `rfc822Name`; the Subject DN `emailAddress` attribute (OID
|
||||
// 1.2.840.113549.1.9.1) is legacy and retained only for old clients, so it must
|
||||
// never outrank the SAN. Collect SAN first, DN second, de-duplicated.
|
||||
//
|
||||
// This was the other way round until a real CA was in the picture: EJBCA
|
||||
// populates BOTH fields, and the two need only differ in case or in domain form
|
||||
// for the DN value to win and every signature to read as "signer ≠ From".
|
||||
function extractEmailAddresses(cert) {
|
||||
const emails = [];
|
||||
|
||||
for (const tv of cert.subject.typesAndValues) {
|
||||
if (tv.type === '1.2.840.113549.1.9.1') {
|
||||
emails.push(tv.value.valueBlock.value);
|
||||
}
|
||||
}
|
||||
const push = (value) => {
|
||||
if (typeof value !== 'string' || !value) return;
|
||||
if (!emails.some((e) => e.toLowerCase() === value.toLowerCase())) emails.push(value);
|
||||
};
|
||||
|
||||
const sanExt = cert.extensions?.find((e) => e.extnID === OID_SAN);
|
||||
if (sanExt) {
|
||||
@@ -156,16 +165,34 @@ function extractEmailAddresses(cert) {
|
||||
}
|
||||
if (names) {
|
||||
for (const name of names) {
|
||||
if (name.type === 1 && typeof name.value === 'string' && !emails.includes(name.value)) {
|
||||
emails.push(name.value);
|
||||
}
|
||||
if (name.type === 1) push(name.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const tv of cert.subject.typesAndValues) {
|
||||
if (tv.type === '1.2.840.113549.1.9.1') push(tv.value.valueBlock.value);
|
||||
}
|
||||
|
||||
return emails;
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff `address` is asserted by the certificate, comparing against EVERY
|
||||
* address it carries rather than only the first.
|
||||
*
|
||||
* A certificate may legitimately name several addresses — an alias, a role
|
||||
* mailbox, a maiden name — and there is no ordering guarantee that puts the one
|
||||
* a given message was sent from at position 0. Comparing only `[0]` reports a
|
||||
* genuine signer as a mismatch, which since the fix-1 auto-import gate is not
|
||||
* cosmetic: it stops the encryption certificate from ever being stored.
|
||||
*/
|
||||
export function certAssertsAddress(emailAddresses, address) {
|
||||
if (!address || !Array.isArray(emailAddresses)) return false;
|
||||
const want = address.toLowerCase();
|
||||
return emailAddresses.some((e) => typeof e === 'string' && e.toLowerCase() === want);
|
||||
}
|
||||
|
||||
/** Determine signing/encryption capabilities from KU / EKU. Tolerant of absent extensions. */
|
||||
export function classifyCapabilities(cert) {
|
||||
const ku = extractKeyUsage(cert);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import * as pkijs from 'pkijs';
|
||||
import * as asn1js from 'asn1js';
|
||||
import { extractCertificateInfo } from './certificate-utils.js';
|
||||
import { extractCertificateInfo, certAssertsAddress } from './certificate-utils.js';
|
||||
import { nativeEngine } from './crypto-engine.js';
|
||||
import { arraysEqual, toHex } from './util.js';
|
||||
|
||||
@@ -58,7 +58,12 @@ export async function smimeVerify(cmsBytes, fromHeader) {
|
||||
if (certExpired && !signatureError) signatureError = 'Signer certificate has expired';
|
||||
if (certNotYetValid && !signatureError) signatureError = 'Signer certificate is not yet valid';
|
||||
|
||||
const signerEmail = certInfo.emailAddresses[0] ?? '';
|
||||
// File the certificate under the address the message actually came from when
|
||||
// the certificate asserts it. That address is the key encryption lookups use
|
||||
// later, so picking a different one of the certificate's addresses stores a
|
||||
// usable certificate where nothing will ever look for it.
|
||||
const asserted = certAssertsAddress(certInfo.emailAddresses, fromHeader);
|
||||
const signerEmail = (asserted ? fromHeader : certInfo.emailAddresses[0]) ?? '';
|
||||
const signerPublicCert = {
|
||||
id: `signer-${certInfo.fingerprint}`,
|
||||
email: signerEmail.toLowerCase(),
|
||||
@@ -72,8 +77,8 @@ export async function smimeVerify(cmsBytes, fromHeader) {
|
||||
};
|
||||
|
||||
let signerEmailMatch;
|
||||
if (fromHeader && signerEmail) {
|
||||
signerEmailMatch = fromHeader.toLowerCase() === signerEmail.toLowerCase();
|
||||
if (fromHeader && certInfo.emailAddresses.length > 0) {
|
||||
signerEmailMatch = asserted;
|
||||
}
|
||||
|
||||
const issuerDer = new Uint8Array(signerCert.issuer.toSchema().toBER(false));
|
||||
|
||||
Reference in New Issue
Block a user