fix(certificates): unify OID decoding and validate reports

This commit is contained in:
morluto committed 2026-10-27 10:40:48 +08:00
1 parent f061989ad2
commit 79b8219f29
6 files changed
+202 -45

No files matched your search

@@ -1,3 +1,7 @@
import { createHash } from "node:crypto";
import { inspectPeCertificatesInputSchema } from "../../domain/native/peCertificates.js";
import { peCertificateFixture } from "../../native/pe/PeCertificates.fixture.js";
import { parsePeCertificates } from "../../native/pe/PeCertificateParser.js";
import { expect, it } from "vitest";
import { PeCertificatesService } from "./PeCertificatesService.js";
import { PE_CERTIFICATES_PROVIDER } from "../../domain/investigationProviders.js";
@@ -6,6 +10,7 @@ import { ok } from "../../domain/result.js";
import {
AnalysisCancelledError,
AnalysisInputError,
AnalysisOutputError,
} from "../../domain/analysisErrorCore.js";
const identity = PE_CERTIFICATES_PROVIDER;
@@ -74,3 +79,81 @@ it("discards an observation if the caller cancels while the provider completes",
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisCancelledError);
});
it("retains complete certificate provenance from the production parser", async () => {
const parsed = parsedCertificateReport();
const result = await new PeCertificatesService({
identity,
inspect: () => Promise.resolve(ok(parsed)),
}).inspect({ path: parsed.artifact.path });
expect(result.ok).toBe(true);
if (!result.ok) throw result.error;
expect(result.value.normalized_result).toEqual(parsed);
});
it.each([
[
"invented examined entry",
(value: PeCertificates) => {
value.coverage.examined_entries += 1;
},
],
[
"entries without a table",
(value: PeCertificates) => {
value.table = null;
},
],
[
"data directory outside the artifact",
(value: PeCertificates) => {
value.table!.data_directory_location.offset = value.artifact.bytes;
},
],
[
"entry outside its table",
(value: PeCertificates) => {
value.entries[0]!.location.offset = 0;
},
],
[
"payload outside its entry",
(value: PeCertificates) => {
value.entries[0]!.payload.location.offset = 0;
},
],
[
"certificate outside its payload",
(value: PeCertificates) => {
value.entries[0]!.pkcs7!.certificates[0]!.location.offset = 0;
},
],
] as const)(
"rejects %s before creating observed Evidence",
async (_label, mutate) => {
const parsed = parsedCertificateReport();
mutate(parsed);
const result = await new PeCertificatesService({
identity,
inspect: () => Promise.resolve(ok(parsed)),
}).inspect({ path: parsed.artifact.path });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError);
},
);
function parsedCertificateReport(): PeCertificates {
const bytes = peCertificateFixture(false);
const input = inspectPeCertificatesInputSchema.parse({
path: report.artifact.path,
});
return parsePeCertificates(
bytes,
{
path: input.path,
sha256: createHash("sha256").update(bytes).digest("hex"),
bytes: bytes.length,
},
input,
);
}
@@ -99,16 +99,34 @@ const validReport = (
if (
report.artifact.bytes > maxBytes ||
report.coverage.examined_entries > maxEntries ||
report.coverage.examined_entries !== report.entries.length ||
report.coverage.certificates !==
report.entries.reduce(
(count, entry) => count + (entry.pkcs7?.certificates.length ?? 0),
0,
) ||
report.entries.length > maxEntries
)
)
return false;
if (report.table !== null && !inside(report.table.location)) return false;
if (report.table === null) return report.entries.length === 0;
if (
!inside(report.table.location) ||
!inside(report.table.data_directory_location)
)
return false;
const table = report.table;
const contains = (
outer: { offset: number; bytes: number },
inner: { offset: number; bytes: number },
) =>
inner.offset >= outer.offset &&
inner.offset - outer.offset <= outer.bytes - inner.bytes;
return report.entries.every(
(entry) => inside(entry.location) && inside(entry.payload.location),
(entry) =>
contains(table.location, entry.location) &&
contains(entry.location, entry.payload.location) &&
(entry.pkcs7?.certificates.every((certificate) =>
contains(entry.payload.location, certificate.location),
) ??
true),
);
};
+6 -18
View File
@@ -17,6 +17,7 @@
* conflated with malformed input.
*/
import { type Result, err, ok } from "../result.js";
import { decodeAsn1Oid } from "../asn1Oid.js";
import { Asn1BerFailure, Asn1BerReader, type Asn1BerNode } from "../asn1Ber.js";
export type ApkV1BlockError =
@@ -152,24 +153,11 @@ const expectOctetString = (
/** Dotted OID text; arcs are accumulated as BigInt so no arc overflows. */
const oidText = (content: Uint8Array): string => {
if (content.length === 0 || content.length > 64)
throw malformed(`object identifier of ${String(content.length)} bytes`);
const arcs: bigint[] = [];
let value = 0n;
for (const [index, byte] of content.entries()) {
value = (value << 7n) | BigInt(byte & 0x7f);
if ((byte & 0x80) !== 0) {
if (index === content.length - 1)
throw malformed("object identifier is truncated");
continue;
}
if (arcs.length === 0) {
const first = value < 80n ? value / 40n : 2n;
arcs.push(first, value - first * 40n);
} else arcs.push(value);
value = 0n;
}
return arcs.join(".");
const decoded = decodeAsn1Oid(content, { bytes: 64, arcBytes: 64 });
if (decoded.ok) return decoded.value;
if (decoded.error.kind === "truncated")
throw malformed("object identifier is truncated");
throw malformed(`object identifier of ${String(content.length)} bytes`);
};
const algorithmOid = (tlv: Asn1BerNode | undefined, what: string) =>
+42
View File
@@ -0,0 +1,42 @@
import { expect, it } from "vitest";
import { decodeAsn1Oid } from "./asn1Oid.js";
const limits = { bytes: 64, arcBytes: 32 };
it("decodes combined initial arcs and later arcs beyond JavaScript integer precision", () => {
expect(decodeAsn1Oid(Uint8Array.of(0x88, 0x37, 0x03), limits)).toEqual({
ok: true,
value: "2.999.3",
});
expect(
decodeAsn1Oid(
Uint8Array.of(0x2a, 0xa0, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x00),
limits,
),
).toEqual({ ok: true, value: "1.2.18014398509481984" });
});
it("distinguishes malformed arcs from each caller-selected capacity bound", () => {
expect(decodeAsn1Oid(new Uint8Array(), limits)).toEqual({
ok: false,
error: { kind: "empty" },
});
expect(decodeAsn1Oid(Uint8Array.of(0x2a, 0x80), limits)).toEqual({
ok: false,
error: { kind: "truncated" },
});
const content = Uint8Array.of(0x2a, 0x81, 0x00);
expect(decodeAsn1Oid(content, { bytes: 3, arcBytes: 2 })).toEqual({
ok: true,
value: "1.2.128",
});
expect(decodeAsn1Oid(content, { bytes: 2, arcBytes: 2 })).toEqual({
ok: false,
error: { kind: "byte-limit", limit: 2 },
});
expect(decodeAsn1Oid(content, { bytes: 3, arcBytes: 1 })).toEqual({
ok: false,
error: { kind: "arc-limit", limit: 1 },
});
});
+33
View File
@@ -0,0 +1,33 @@
import { err, ok, type Result } from "./result.js";
/** Format owners choose their admission bounds; the shared decoder owns BER arcs. */
export type Asn1OidError =
| { readonly kind: "empty" | "truncated" }
| { readonly kind: "byte-limit" | "arc-limit"; readonly limit: number };
/** Decode OBJECT IDENTIFIER content without narrowing arbitrary-size base-128 arcs. */
export const decodeAsn1Oid = (
content: Uint8Array,
limits: { readonly bytes: number; readonly arcBytes: number },
): Result<string, Asn1OidError> => {
if (content.length === 0) return err({ kind: "empty" });
if (content.length > limits.bytes)
return err({ kind: "byte-limit", limit: limits.bytes });
const arcs: bigint[] = [];
let value = 0n;
let arcBytes = 0;
for (const byte of content) {
arcBytes += 1;
if (arcBytes > limits.arcBytes)
return err({ kind: "arc-limit", limit: limits.arcBytes });
value = (value << 7n) | BigInt(byte & 0x7f);
if ((byte & 0x80) !== 0) continue;
if (arcs.length === 0) {
const first = value < 80n ? value / 40n : 2n;
arcs.push(first, value - first * 40n);
} else arcs.push(value);
value = 0n;
arcBytes = 0;
}
return arcBytes === 0 ? ok(arcs.join(".")) : err({ kind: "truncated" });
};
+16 -23
View File
@@ -1,3 +1,4 @@
import { decodeAsn1Oid } from "../../domain/asn1Oid.js";
import {
Asn1BerFailure,
Asn1BerReader,
@@ -85,33 +86,25 @@ const chargeDecodedName = (budget: DecodedNameBudget, bytes: number): void => {
/** Decode an OBJECT IDENTIFIER content buffer as dotted decimal. */
export const decodeOid = (content: Buffer): string => {
if (content.length === 0) return fail("Empty OBJECT IDENTIFIER.");
if (content.length > MAX_OID_BYTES)
return fail(`OBJECT IDENTIFIER exceeds ${String(MAX_OID_BYTES)} bytes.`);
// Arcs are unbounded base-128 integers; the first encoded value combines
// the first two arcs and can itself span several bytes (2.999 -> 1079).
const values: bigint[] = [];
let value = 0n;
let arcBytes = 0;
for (let index = 0; index < content.length; index += 1) {
arcBytes += 1;
if (arcBytes > MAX_OID_ARC_BYTES)
const decoded = decodeAsn1Oid(content, {
bytes: MAX_OID_BYTES,
arcBytes: MAX_OID_ARC_BYTES,
});
if (decoded.ok) return decoded.value;
switch (decoded.error.kind) {
case "empty":
return fail("Empty OBJECT IDENTIFIER.");
case "byte-limit":
return fail(
`OBJECT IDENTIFIER arc exceeds ${String(MAX_OID_ARC_BYTES)} bytes.`,
`OBJECT IDENTIFIER exceeds ${String(decoded.error.limit)} bytes.`,
);
const byte = required(content[index], "Truncated OBJECT IDENTIFIER.");
value = (value << 7n) | BigInt(byte & 0x7f);
if ((byte & 0x80) === 0) {
values.push(value);
value = 0n;
arcBytes = 0;
} else if (index === content.length - 1)
case "arc-limit":
return fail(
`OBJECT IDENTIFIER arc exceeds ${String(decoded.error.limit)} bytes.`,
);
case "truncated":
return fail("Truncated OBJECT IDENTIFIER component.");
}
const [combined, ...rest] = values;
if (combined === undefined) return fail("Empty OBJECT IDENTIFIER.");
const first = combined < 40n ? 0n : combined < 80n ? 1n : 2n;
return [first, combined - first * 40n, ...rest].join(".");
};
const decodeUtf16Be = (bytes: Buffer, budget: DecodedNameBudget): string => {