1 parent
98a2322998
commit
553262443a
6 files changed
+221
-67
No files matched your search
@@ -52,5 +52,13 @@ share a 64 KiB budget per PKCS#7 payload. Unsupported certificate name
|
||||
encodings, including TeletexString (T.61) and GeneralString, fail explicitly
|
||||
rather than returning names decoded with an assumed character set.
|
||||
|
||||
The structural CMS reader accepts definite lengths and BER indefinite lengths
|
||||
for constructed values, including the outer `ContentInfo` sequence. Primitive
|
||||
values must have definite lengths. Embedded X.509 certificates and signed
|
||||
attributes must also use definite length encoding, as required by their DER
|
||||
profiles. This is a bounded structural subset: it does not implement every BER
|
||||
string form or validate DER canonical encoding, and it does not verify
|
||||
signatures.
|
||||
|
||||
This workflow does not verify signatures, certificate chains, timestamps, or
|
||||
Authenticode digest coverage of image bytes.
|
||||
@@ -19,24 +19,26 @@ const OID_NAMES: Readonly<Record<string, string>> = {
|
||||
"2.16.840.1.101.3.4.2.3": "sha512",
|
||||
};
|
||||
|
||||
export interface DerNode {
|
||||
export interface Asn1Node {
|
||||
readonly tag: number;
|
||||
readonly constructed: boolean;
|
||||
readonly lengthForm: "definite" | "indefinite";
|
||||
readonly start: number;
|
||||
readonly contentEnd: number;
|
||||
readonly end: number;
|
||||
readonly content: Buffer;
|
||||
readonly children: readonly DerNode[];
|
||||
readonly children: readonly Asn1Node[];
|
||||
}
|
||||
|
||||
export class PeCertificateDerFailure extends Error {
|
||||
export class PeCertificateAsn1Failure extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "PeCertificateDerFailure";
|
||||
this.name = "PeCertificateAsn1Failure";
|
||||
}
|
||||
}
|
||||
|
||||
const fail = (message: string): never => {
|
||||
throw new PeCertificateDerFailure(message);
|
||||
throw new PeCertificateAsn1Failure(message);
|
||||
};
|
||||
|
||||
const required = <T>(value: T | undefined, message: string): T =>
|
||||
@@ -44,68 +46,106 @@ const required = <T>(value: T | undefined, message: string): T =>
|
||||
|
||||
// Authenticode signatures with full chains and countersignatures decode to a
|
||||
// few thousand nodes; the budget bounds sibling fan-out, which depth does not.
|
||||
export const DER_NODE_BUDGET = 16_384;
|
||||
export const ASN1_NODE_BUDGET = 16_384;
|
||||
|
||||
interface DerBudget {
|
||||
interface Asn1Budget {
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
/** Decode one DER/BER TLV; constructed nodes own their child sequence. */
|
||||
export const readDer = (
|
||||
/** Decode one bounded ASN.1 TLV, including indefinite constructed BER values. */
|
||||
export const readAsn1 = (
|
||||
bytes: Buffer,
|
||||
offset = 0,
|
||||
end = bytes.length,
|
||||
depth = 0,
|
||||
budget: DerBudget = { remaining: DER_NODE_BUDGET },
|
||||
): DerNode => {
|
||||
if (depth > 32) return fail("DER nesting exceeds 32 levels.");
|
||||
budget: Asn1Budget = { remaining: ASN1_NODE_BUDGET },
|
||||
): Asn1Node => {
|
||||
if (depth > 32) return fail("ASN.1 nesting exceeds 32 levels.");
|
||||
if (budget.remaining <= 0)
|
||||
return fail(
|
||||
`DER value count exceeds the ${String(DER_NODE_BUDGET)}-node budget.`,
|
||||
`ASN.1 value count exceeds the ${String(ASN1_NODE_BUDGET)}-node budget.`,
|
||||
);
|
||||
budget.remaining -= 1;
|
||||
if (offset >= end || offset + 2 > end)
|
||||
return fail("Truncated DER tag or length.");
|
||||
const tag = required(bytes[offset], "Truncated DER tag.");
|
||||
return fail("Truncated ASN.1 tag or length.");
|
||||
const tag = required(bytes[offset], "Truncated ASN.1 tag.");
|
||||
if (tag === 0)
|
||||
return fail(
|
||||
"Unexpected end-of-contents marker outside an indefinite value.",
|
||||
);
|
||||
const constructed = (tag & 0x20) !== 0;
|
||||
let cursor = offset + 1;
|
||||
const lengthByte = required(bytes[cursor], "Truncated DER length.");
|
||||
const lengthByte = required(bytes[cursor], "Truncated ASN.1 length.");
|
||||
cursor += 1;
|
||||
let length: number;
|
||||
if ((lengthByte & 0x80) === 0) length = lengthByte;
|
||||
let length = 0;
|
||||
const indefinite = lengthByte === 0x80;
|
||||
if (indefinite) {
|
||||
if (!constructed)
|
||||
return fail("Primitive ASN.1 values cannot use indefinite length.");
|
||||
} else if ((lengthByte & 0x80) === 0) length = lengthByte;
|
||||
else {
|
||||
const width = lengthByte & 0x7f;
|
||||
if (width === 0 || width > 4) return fail("Unsupported DER length form.");
|
||||
if (cursor + width > end) return fail("Truncated DER long length.");
|
||||
if (width > 4) return fail("Unsupported ASN.1 long-form length width.");
|
||||
if (cursor + width > end) return fail("Truncated ASN.1 long length.");
|
||||
length = 0;
|
||||
for (let index = 0; index < width; index += 1) {
|
||||
length = (length << 8) | required(bytes[cursor], "Truncated DER length.");
|
||||
length =
|
||||
(length << 8) | required(bytes[cursor], "Truncated ASN.1 length.");
|
||||
cursor += 1;
|
||||
}
|
||||
}
|
||||
const contentEnd = cursor + length;
|
||||
if (length < 0 || contentEnd > end)
|
||||
return fail("DER value exceeds its parent.");
|
||||
const content = bytes.subarray(cursor, contentEnd);
|
||||
const children: DerNode[] = [];
|
||||
if (constructed) {
|
||||
const children: Asn1Node[] = [];
|
||||
let contentEnd = cursor;
|
||||
let encodedEnd = cursor;
|
||||
if (indefinite) {
|
||||
let at = cursor;
|
||||
let terminated = false;
|
||||
while (at < end) {
|
||||
if (at + 1 < end && bytes[at] === 0 && bytes[at + 1] === 0) {
|
||||
contentEnd = at;
|
||||
encodedEnd = at + 2;
|
||||
terminated = true;
|
||||
break;
|
||||
}
|
||||
const child = readAsn1(bytes, at, end, depth + 1, budget);
|
||||
children.push(child);
|
||||
at = child.end;
|
||||
}
|
||||
if (!terminated)
|
||||
return fail("Indefinite ASN.1 value has no end-of-contents marker.");
|
||||
} else {
|
||||
contentEnd = cursor + length;
|
||||
if (length < 0 || contentEnd > end)
|
||||
return fail("ASN.1 value exceeds its parent.");
|
||||
encodedEnd = contentEnd;
|
||||
}
|
||||
if (constructed && !indefinite) {
|
||||
let at = cursor;
|
||||
while (at < contentEnd) {
|
||||
const child = readDer(bytes, at, contentEnd, depth + 1, budget);
|
||||
const child = readAsn1(bytes, at, contentEnd, depth + 1, budget);
|
||||
children.push(child);
|
||||
at = child.end;
|
||||
}
|
||||
}
|
||||
const content = bytes.subarray(cursor, contentEnd);
|
||||
return {
|
||||
tag,
|
||||
constructed,
|
||||
lengthForm: indefinite ? "indefinite" : "definite",
|
||||
start: offset,
|
||||
end: contentEnd,
|
||||
contentEnd,
|
||||
end: encodedEnd,
|
||||
content,
|
||||
children,
|
||||
};
|
||||
};
|
||||
|
||||
const requireDefiniteEncoding = (node: Asn1Node, label: string): void => {
|
||||
if (node.lengthForm === "indefinite")
|
||||
fail(`${label} must use definite DER length encoding.`);
|
||||
for (const child of node.children) requireDefiniteEncoding(child, label);
|
||||
};
|
||||
|
||||
export const oidName = (oid: string): string | null => OID_NAMES[oid] ?? null;
|
||||
|
||||
// One arc grows a BigInt by a shift per byte. Certificate OIDs, including a
|
||||
@@ -209,7 +249,7 @@ const decodeUtf8 = (bytes: Buffer, budget: DecodedNameBudget): string => {
|
||||
};
|
||||
|
||||
const decodeDirectoryString = (
|
||||
node: DerNode,
|
||||
node: Asn1Node,
|
||||
budget: DecodedNameBudget,
|
||||
): string => {
|
||||
switch (node.tag) {
|
||||
@@ -250,18 +290,18 @@ const requireBitStringContent = (content: Buffer, label: string): void => {
|
||||
return fail(`${label} BIT STRING is truncated or has invalid unused bits.`);
|
||||
};
|
||||
|
||||
const requireBitString = (node: DerNode | undefined, label: string): void => {
|
||||
const requireBitString = (node: Asn1Node | undefined, label: string): void => {
|
||||
if (node === undefined || node.tag !== 0x03)
|
||||
return fail(`${label} is not a BIT STRING.`);
|
||||
requireBitStringContent(node.content, label);
|
||||
};
|
||||
|
||||
const sequence = (node: DerNode, label: string): readonly DerNode[] => {
|
||||
const sequence = (node: Asn1Node, label: string): readonly Asn1Node[] => {
|
||||
if (node.tag !== 0x30) return fail(`Expected SEQUENCE for ${label}.`);
|
||||
return node.children;
|
||||
};
|
||||
|
||||
const algorithmOid = (node: DerNode): string => {
|
||||
const algorithmOid = (node: Asn1Node): string => {
|
||||
const children = sequence(node, "AlgorithmIdentifier");
|
||||
const oid = children[0];
|
||||
if (oid === undefined || oid.tag !== 0x06)
|
||||
@@ -276,7 +316,7 @@ interface Attribute {
|
||||
}
|
||||
|
||||
const decodeName = (
|
||||
node: DerNode,
|
||||
node: Asn1Node,
|
||||
budget: DecodedNameBudget,
|
||||
): {
|
||||
readonly text: string;
|
||||
@@ -312,7 +352,7 @@ const decodeName = (
|
||||
return { text, attributes };
|
||||
};
|
||||
|
||||
const validityTime = (node: DerNode, budget: DecodedNameBudget): string => {
|
||||
const validityTime = (node: Asn1Node, budget: DecodedNameBudget): string => {
|
||||
if (node.tag !== 0x17 && node.tag !== 0x18)
|
||||
return fail("Validity instant is not UTCTime or GeneralizedTime.");
|
||||
// A zero byte becomes "\u0000" (six characters) in the hashed evidence JSON.
|
||||
@@ -331,7 +371,7 @@ export interface ParsedCertificate {
|
||||
readonly signature_algorithm_oid: string;
|
||||
}
|
||||
|
||||
const requireSubjectPublicKeyInfo = (node: DerNode): void => {
|
||||
const requireSubjectPublicKeyInfo = (node: Asn1Node): void => {
|
||||
const children = sequence(node, "SubjectPublicKeyInfo");
|
||||
const algorithm = children[0];
|
||||
const key = children[1];
|
||||
@@ -346,7 +386,7 @@ const requireSubjectPublicKeyInfo = (node: DerNode): void => {
|
||||
// issuerUniqueID [1], subjectUniqueID [2], and extensions [3] are optional
|
||||
// and ordered. Anything else is not a TBSCertificate field.
|
||||
const finishTbsCertificate = (
|
||||
children: readonly DerNode[],
|
||||
children: readonly Asn1Node[],
|
||||
start: number,
|
||||
): void => {
|
||||
let cursor = start;
|
||||
@@ -383,9 +423,10 @@ const finishTbsCertificate = (
|
||||
|
||||
/** Decode one X.509 Certificate SEQUENCE at a known file-relative DER node. */
|
||||
export const parseCertificate = (
|
||||
node: DerNode,
|
||||
node: Asn1Node,
|
||||
names: DecodedNameBudget = { remaining: MAX_DECODED_NAME_BYTES },
|
||||
): ParsedCertificate => {
|
||||
requireDefiniteEncoding(node, "X.509 Certificate");
|
||||
const children = sequence(node, "Certificate");
|
||||
const tbs = children[0];
|
||||
const signatureAlgorithm = children[1];
|
||||
@@ -467,7 +508,7 @@ export interface ParsedSigner {
|
||||
}
|
||||
|
||||
const signerIdentifier = (
|
||||
sid: DerNode,
|
||||
sid: Asn1Node,
|
||||
names: DecodedNameBudget,
|
||||
): {
|
||||
readonly issuer: ReturnType<typeof decodeName> | null;
|
||||
@@ -505,7 +546,10 @@ const signerIdentifier = (
|
||||
);
|
||||
};
|
||||
|
||||
const parseSigner = (node: DerNode, names: DecodedNameBudget): ParsedSigner => {
|
||||
const parseSigner = (
|
||||
node: Asn1Node,
|
||||
names: DecodedNameBudget,
|
||||
): ParsedSigner => {
|
||||
const children = sequence(node, "SignerInfo");
|
||||
const version = children[0];
|
||||
const sid = children[1];
|
||||
@@ -520,7 +564,10 @@ const parseSigner = (node: DerNode, names: DecodedNameBudget): ParsedSigner => {
|
||||
);
|
||||
let cursor = 3;
|
||||
const authenticated = children[cursor]?.tag === 0xa0;
|
||||
if (authenticated) cursor += 1;
|
||||
if (authenticated) {
|
||||
requireDefiniteEncoding(children[cursor]!, "SignerInfo signedAttrs");
|
||||
cursor += 1;
|
||||
}
|
||||
const signatureAlg = children[cursor];
|
||||
if (signatureAlg === undefined)
|
||||
return fail("SignerInfo is missing signatureAlgorithm.");
|
||||
@@ -551,7 +598,7 @@ export interface ParsedSignedData {
|
||||
}
|
||||
|
||||
const parseSignedData = (
|
||||
node: DerNode,
|
||||
node: Asn1Node,
|
||||
names: DecodedNameBudget,
|
||||
): ParsedSignedData => {
|
||||
const children = sequence(node, "SignedData");
|
||||
@@ -597,7 +644,8 @@ const parseSignedData = (
|
||||
|
||||
/** Decode ContentInfo wrapping SignedData; other content types remain unsupported. */
|
||||
export const parsePkcs7SignedData = (bytes: Buffer): ParsedSignedData => {
|
||||
const root = readDer(bytes);
|
||||
const root = readAsn1(bytes);
|
||||
if (root.end !== bytes.length) return fail("ContentInfo has trailing data.");
|
||||
const children = sequence(root, "ContentInfo");
|
||||
const type = children[0];
|
||||
const content = children[1];
|
||||
@@ -9,17 +9,18 @@ import {
|
||||
peCertificateFixture,
|
||||
peWithoutCertificateTableFixture,
|
||||
pkcs7SignedDataFixture,
|
||||
pkcs7SignedDataIndefiniteEnvelopeFixture,
|
||||
} from "./PeCertificates.fixture.js";
|
||||
import {
|
||||
DER_NODE_BUDGET,
|
||||
ASN1_NODE_BUDGET,
|
||||
MAX_DECODED_NAME_BYTES,
|
||||
MAX_OID_ARC_BYTES,
|
||||
MAX_OID_BYTES,
|
||||
decodeOid,
|
||||
parseCertificate,
|
||||
PeCertificateDerFailure,
|
||||
readDer,
|
||||
} from "./PeCertificateDer.js";
|
||||
PeCertificateAsn1Failure,
|
||||
readAsn1,
|
||||
} from "./PeCertificateAsn1.js";
|
||||
import { parsePeCertificates } from "./PeCertificateParser.js";
|
||||
|
||||
const defaults = inspectPeCertificatesInputSchema.parse({
|
||||
@@ -71,6 +72,61 @@ it("reads PE32 and PE32+ PKCS#7 certificate tables", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("reads BER indefinite constructed ContentInfo and preserves certificate bytes", () => {
|
||||
const payload = pkcs7SignedDataIndefiniteEnvelopeFixture();
|
||||
const root = readAsn1(payload);
|
||||
const certificate = root.children[1]?.children[0]?.children[3]?.children[0];
|
||||
if (certificate === undefined)
|
||||
throw new Error("Fixture certificate was not decoded");
|
||||
const report = parse(peCertificateFixture(false, payload));
|
||||
const decoded = report.entries[0]?.pkcs7?.certificates[0];
|
||||
expect(decoded).toMatchObject({
|
||||
location: {
|
||||
offset: 0x200 + 8 + certificate.start,
|
||||
bytes: certificate.end - certificate.start,
|
||||
},
|
||||
serial_hex: "11223344",
|
||||
});
|
||||
expect(decoded?.der_sha256).toBe(
|
||||
createHash("sha256")
|
||||
.update(payload.subarray(certificate.start, certificate.end))
|
||||
.digest("hex"),
|
||||
);
|
||||
});
|
||||
|
||||
it("matches nested indefinite constructed values and rejects invalid EOC forms", () => {
|
||||
const nested = readAsn1(
|
||||
Buffer.from([0x30, 0x80, 0x30, 0x80, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00]),
|
||||
);
|
||||
expect(nested).toMatchObject({
|
||||
lengthForm: "indefinite",
|
||||
end: 10,
|
||||
contentEnd: 8,
|
||||
content: Buffer.from([0x30, 0x80, 0x05, 0x00, 0x00, 0x00]),
|
||||
children: [
|
||||
{
|
||||
lengthForm: "indefinite",
|
||||
end: 8,
|
||||
contentEnd: 6,
|
||||
content: Buffer.from([0x05, 0x00]),
|
||||
children: [{ tag: 0x05, lengthForm: "definite", end: 6 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(() => readAsn1(Buffer.from([0x04, 0x80, 0x00, 0x00]))).toThrow(
|
||||
/Primitive ASN.1 values cannot use indefinite length/u,
|
||||
);
|
||||
expect(() => readAsn1(Buffer.from([0x30, 0x02, 0x00, 0x00]))).toThrow(
|
||||
/Unexpected end-of-contents/u,
|
||||
);
|
||||
expect(() => readAsn1(Buffer.from([0x30, 0x80, 0x05, 0x00]))).toThrow(
|
||||
/no end-of-contents marker/u,
|
||||
);
|
||||
expect(() =>
|
||||
parseCertificate(readAsn1(Buffer.from([0x30, 0x80, 0x00, 0x00]))),
|
||||
).toThrow(/X.509 Certificate must use definite DER length encoding/u);
|
||||
});
|
||||
|
||||
it("reports an absent security directory without claiming the image is unsigned", () => {
|
||||
const report = parse(peWithoutCertificateTableFixture());
|
||||
expect(report.table).toBeNull();
|
||||
@@ -160,7 +216,7 @@ const contentInfo = (signedData: Buffer) =>
|
||||
|
||||
it("rejects extra ContentInfo and signedData wrapper values", () => {
|
||||
const valid = pkcs7SignedDataFixture();
|
||||
const root = readDer(valid);
|
||||
const root = readAsn1(valid);
|
||||
const type = root.children[0];
|
||||
const content = root.children[1];
|
||||
const signedData = content?.children[0];
|
||||
@@ -313,8 +369,8 @@ it("fails a truncated issuerAndSerialNumber instead of dropping the signer", ()
|
||||
);
|
||||
});
|
||||
|
||||
it("stops decoding a certificate payload at the DER node budget", () => {
|
||||
const values = Buffer.alloc(2 * (DER_NODE_BUDGET + 1));
|
||||
it("stops decoding a certificate payload at the ASN.1 node budget", () => {
|
||||
const values = Buffer.alloc(2 * (ASN1_NODE_BUDGET + 1));
|
||||
for (let at = 0; at < values.length; at += 2) values[at] = 0x05;
|
||||
const payload = contentInfo(derSeq(derInt(1), der(0x31, values)));
|
||||
expect(() => parse(peCertificateFixture(false, payload))).toThrow(
|
||||
@@ -379,11 +435,11 @@ it("rejects a certificate missing subjectPublicKeyInfo or signatureValue", () =>
|
||||
);
|
||||
expect(() =>
|
||||
parseCertificate(
|
||||
readDer(derSeq(tbs(false), SHA256_ALG, der(0x03, Buffer.from([0, 1])))),
|
||||
readAsn1(derSeq(tbs(false), SHA256_ALG, der(0x03, Buffer.from([0, 1])))),
|
||||
),
|
||||
).toThrow(/subjectPublicKeyInfo/u);
|
||||
expect(() =>
|
||||
parseCertificate(readDer(derSeq(tbs(true), SHA256_ALG))),
|
||||
parseCertificate(readAsn1(derSeq(tbs(true), SHA256_ALG))),
|
||||
).toThrow(/signatureValue/u);
|
||||
const signer = derSeq(
|
||||
derInt(1),
|
||||
@@ -406,7 +462,7 @@ it("rejects a certificate missing subjectPublicKeyInfo or signatureValue", () =>
|
||||
);
|
||||
expect(() =>
|
||||
parseCertificate(
|
||||
readDer(
|
||||
readAsn1(
|
||||
derSeq(
|
||||
derSeq(
|
||||
derInt(1),
|
||||
@@ -426,7 +482,7 @@ it("rejects a certificate missing subjectPublicKeyInfo or signatureValue", () =>
|
||||
});
|
||||
|
||||
it("rejects a UTF8String name that is not valid UTF-8", () => {
|
||||
const certificate = readDer(
|
||||
const certificate = readAsn1(
|
||||
namedCertificate(
|
||||
directoryName(der(0x0c, Buffer.from([0x80]))),
|
||||
directoryName(der(0x0c, Buffer.from("A"))),
|
||||
@@ -435,7 +491,7 @@ it("rejects a UTF8String name that is not valid UTF-8", () => {
|
||||
expect(() => parseCertificate(certificate)).toThrow(/not valid UTF-8/u);
|
||||
expect(
|
||||
parseCertificate(
|
||||
readDer(
|
||||
readAsn1(
|
||||
namedCertificate(
|
||||
directoryName(der(0x0c, Buffer.from([0xc3, 0xa9]))),
|
||||
directoryName(der(0x0c, Buffer.from("A"))),
|
||||
@@ -452,14 +508,14 @@ it.each([
|
||||
] as const)(
|
||||
"rejects unsupported %s certificate names",
|
||||
(_encoding, tag, bytes) => {
|
||||
const certificate = readDer(
|
||||
const certificate = readAsn1(
|
||||
namedCertificate(
|
||||
directoryName(der(0x0c, Buffer.from("Issuer"))),
|
||||
directoryName(der(tag, bytes)),
|
||||
),
|
||||
);
|
||||
expect(() => parseCertificate(certificate)).toThrow(
|
||||
new PeCertificateDerFailure(
|
||||
new PeCertificateAsn1Failure(
|
||||
`Unsupported certificate name encoding (DER tag 0x${tag.toString(16).padStart(2, "0")}).`,
|
||||
),
|
||||
);
|
||||
@@ -474,12 +530,12 @@ it("decodes BMPString names once and shares the decoded-byte budget", () => {
|
||||
);
|
||||
return der(0x1e, content);
|
||||
};
|
||||
const certificate = readDer(
|
||||
const certificate = readAsn1(
|
||||
namedCertificate(directoryName(bmp("REA")), directoryName(bmp("Fixture"))),
|
||||
);
|
||||
expect(parseCertificate(certificate).subject.text).toBe("commonName=Fixture");
|
||||
const half = Math.floor((MAX_DECODED_NAME_BYTES - (2 + 13 * 6 * 2)) / 4) * 2;
|
||||
const within = readDer(
|
||||
const within = readAsn1(
|
||||
namedCertificate(
|
||||
directoryName(der(0x1e, Buffer.alloc(half))),
|
||||
directoryName(der(0x1e, Buffer.alloc(half))),
|
||||
@@ -488,7 +544,7 @@ it("decodes BMPString names once and shares the decoded-byte budget", () => {
|
||||
expect(parseCertificate(within).issuer.text.startsWith("commonName=")).toBe(
|
||||
true,
|
||||
);
|
||||
const over = readDer(
|
||||
const over = readAsn1(
|
||||
namedCertificate(
|
||||
directoryName(der(0x1e, Buffer.alloc(half + 4))),
|
||||
directoryName(der(0x1e, Buffer.alloc(half))),
|
||||
@@ -498,7 +554,7 @@ it("decodes BMPString names once and shares the decoded-byte budget", () => {
|
||||
const universal = Buffer.alloc(MAX_DECODED_NAME_BYTES + 4);
|
||||
expect(() =>
|
||||
parseCertificate(
|
||||
readDer(
|
||||
readAsn1(
|
||||
namedCertificate(
|
||||
directoryName(der(0x1c, universal)),
|
||||
directoryName(bmp("A")),
|
||||
@@ -519,11 +575,11 @@ it("decodes UniversalString names as UTF-32BE", () => {
|
||||
const cn = Buffer.from([0x06, 0x03, 0x55, 0x04, 0x03]);
|
||||
const name = derSeq(derSet(derSeq(cn, universal("REA"))));
|
||||
const tbs = derSeq(derInt(1), SHA256_ALG, name, validity, name, spki);
|
||||
const certificate = readDer(
|
||||
const certificate = readAsn1(
|
||||
derSeq(tbs, SHA256_ALG, der(0x03, Buffer.from([0]))),
|
||||
);
|
||||
expect(parseCertificate(certificate).subject.text).toBe("commonName=REA");
|
||||
const emoji = readDer(
|
||||
const emoji = readAsn1(
|
||||
namedCertificate(
|
||||
directoryName(der(0x1c, Buffer.from([0x00, 0x01, 0xf6, 0x00]))),
|
||||
directoryName(der(0x1c, Buffer.from([0x00, 0x01, 0xf6, 0x00]))),
|
||||
@@ -531,7 +587,7 @@ it("decodes UniversalString names as UTF-32BE", () => {
|
||||
);
|
||||
expect(parseCertificate(emoji).subject.text).toBe("commonName=😀");
|
||||
const odd = derSeq(derSet(derSeq(cn, der(0x1c, Buffer.from([0, 0, 0])))));
|
||||
const broken = readDer(
|
||||
const broken = readAsn1(
|
||||
derSeq(
|
||||
derSeq(
|
||||
derInt(1),
|
||||
|
||||
@@ -8,8 +8,8 @@ import { peCertificateTypeName } from "../../domain/native/peCertificates.js";
|
||||
import {
|
||||
oidName,
|
||||
parsePkcs7SignedData,
|
||||
PeCertificateDerFailure,
|
||||
} from "./PeCertificateDer.js";
|
||||
PeCertificateAsn1Failure,
|
||||
} from "./PeCertificateAsn1.js";
|
||||
import { readPeHeaders } from "./PeResourceLayout.js";
|
||||
|
||||
const fail = (message: string): never => {
|
||||
@@ -158,7 +158,7 @@ export const parsePeCertificates = (
|
||||
})),
|
||||
};
|
||||
} catch (cause) {
|
||||
if (cause instanceof PeCertificateDerFailure)
|
||||
if (cause instanceof PeCertificateAsn1Failure)
|
||||
fail(`PKCS#7 signedData is malformed: ${cause.message}`);
|
||||
throw cause;
|
||||
}
|
||||
|
||||
@@ -104,6 +104,22 @@ export const pkcs7SignedDataFixture = (
|
||||
return seq(SIGNED_DATA, context(0, signedData));
|
||||
};
|
||||
|
||||
/** Same CMS fields with a BER indefinite-length outer ContentInfo SEQUENCE. */
|
||||
export const pkcs7SignedDataIndefiniteEnvelopeFixture = (): Buffer => {
|
||||
const definite = pkcs7SignedDataFixture();
|
||||
const firstLength = definite[1];
|
||||
if (firstLength === undefined) throw new Error("Missing ContentInfo length");
|
||||
let contentOffset = 2;
|
||||
if ((firstLength & 0x80) !== 0) contentOffset += firstLength & 0x7f;
|
||||
if (contentOffset > definite.length)
|
||||
throw new Error("Invalid fixture ContentInfo length");
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x30, 0x80]),
|
||||
definite.subarray(contentOffset),
|
||||
Buffer.from([0, 0]),
|
||||
]);
|
||||
};
|
||||
|
||||
/** Minimal PE32/PE32+ image with an overlay IMAGE_DIRECTORY_ENTRY_SECURITY table. */
|
||||
export const peCertificateFixture = (
|
||||
plus = false,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
peCertificateFixture,
|
||||
peWithoutCertificateTableFixture,
|
||||
pkcs7SignedDataFixture,
|
||||
pkcs7SignedDataIndefiniteEnvelopeFixture,
|
||||
} from "../../src/native/pe/PeCertificates.fixture.js";
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../support/cli/cliFixture.js";
|
||||
@@ -29,6 +30,31 @@ cliTest(
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"inspects SignedData with an indefinite constructed ContentInfo envelope",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-pe-cert-ber-cli-");
|
||||
const path = join(root, "ber-signed.exe");
|
||||
const payload = pkcs7SignedDataIndefiniteEnvelopeFixture();
|
||||
await writeFile(path, peCertificateFixture(false, payload));
|
||||
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-pe-certificates", path, "--json"],
|
||||
});
|
||||
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
const evidence = toolContract("inspect_pe_certificates").outputSchema.parse(
|
||||
result.json,
|
||||
);
|
||||
const entry = evidence.normalized_result.entries[0];
|
||||
expect(entry?.pkcs7?.certificates[0]).toMatchObject({
|
||||
serial_hex: "11223344",
|
||||
});
|
||||
expect(entry?.payload.bytes).toBe(payload.length);
|
||||
expect(entry?.pkcs7?.certificates[0]?.location.bytes).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"resolves a relative PE path against the working directory",
|
||||
async ({ cli }) => {
|
||||
|
||||
Reference in new issue
Block a user