diff --git a/src/application/binaryDiagnostics/PeCertificatesService.test.ts b/src/application/binaryDiagnostics/PeCertificatesService.test.ts index 5c0a3afc81..8ee83c487a 100644 --- a/src/application/binaryDiagnostics/PeCertificatesService.test.ts +++ b/src/application/binaryDiagnostics/PeCertificatesService.test.ts @@ -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, + ); +} diff --git a/src/application/binaryDiagnostics/PeCertificatesService.ts b/src/application/binaryDiagnostics/PeCertificatesService.ts index 83b3aec193..b5abebe206 100644 --- a/src/application/binaryDiagnostics/PeCertificatesService.ts +++ b/src/application/binaryDiagnostics/PeCertificatesService.ts @@ -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), ); }; diff --git a/src/domain/apkSignature/apkV1SignatureBlock.ts b/src/domain/apkSignature/apkV1SignatureBlock.ts index c38fabddac..b43859a0f4 100644 --- a/src/domain/apkSignature/apkV1SignatureBlock.ts +++ b/src/domain/apkSignature/apkV1SignatureBlock.ts @@ -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) => diff --git a/src/domain/asn1Oid.test.ts b/src/domain/asn1Oid.test.ts new file mode 100644 index 0000000000..c8240f27ae --- /dev/null +++ b/src/domain/asn1Oid.test.ts @@ -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 }, + }); +}); diff --git a/src/domain/asn1Oid.ts b/src/domain/asn1Oid.ts new file mode 100644 index 0000000000..242022b3e4 --- /dev/null +++ b/src/domain/asn1Oid.ts @@ -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 => { + 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" }); +}; diff --git a/src/native/pe/PeCertificateAsn1.ts b/src/native/pe/PeCertificateAsn1.ts index aaaca68450..75171ad93b 100644 --- a/src/native/pe/PeCertificateAsn1.ts +++ b/src/native/pe/PeCertificateAsn1.ts @@ -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 => {