mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
fix(certificates): unify OID decoding and validate reports
This commit is contained in:
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),
|
||||
);
|
||||
};
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
};
|
||||
@@ -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 => {
|
||||
|
||||
Reference in new issue
Block a user