fix(certificates)!: disclose undecoded unsigned attributes (#7920)

* fix(certificates): disclose undecoded unsigned attributes

* test(certificates): use parser-owned limitation text
This commit is contained in:
morluto authored and GitHub committed 2026-10-29 08:56:07 +08:00
1 parent 0386efd1e1
commit 33d6975749
11 files changed
+183 -9

No files matched your search

+6
View File
@@ -40,6 +40,12 @@ signer identifier facts. A signer identified by subject key identifier reports
that value as `subject_key_identifier_hex`. Other certificate types keep
`pkcs7` null.
Each signer reports `unsigned_attribute_oids`: null means the field was absent,
and an array lists the types when present. Older retained Evidence may omit the
field. Unsigned attribute contents, including nested signatures and timestamp
tokens, are not decoded; their presence makes coverage partial and adds a
limitation. Certificate and signer counts refer to the outer SignedData.
A missing table is observed absence of that directory. It is not proof the
image is unsigned in every catalog or overlay sense.
@@ -128,6 +128,14 @@ it.each([
value.entries[0]!.pkcs7!.certificates[0]!.location.offset = 0;
},
],
[
"complete coverage with undecoded unsigned attributes",
(value: PeCertificates) => {
value.entries[0]!.pkcs7!.signers[0]!.unsigned_attribute_oids = [
"1.3.6.1.4.1.311.3.3.1",
];
},
],
] as const)(
"rejects %s before creating observed Evidence",
async (_label, mutate) => {
@@ -107,6 +107,15 @@ const validReport = (
)
)
return false;
if (
report.coverage.status === "complete" &&
report.entries.some((entry) =>
entry.pkcs7?.signers.some((signer) =>
Array.isArray(signer.unsigned_attribute_oids),
),
)
)
return false;
if (report.table === null) return report.entries.length === 0;
if (
!inside(report.table.location) ||
@@ -245,7 +245,7 @@ export const APK_SIGNATURE_LIMITATIONS: readonly string[] = [
"Static structural inspection only. No Android code executes, no device or adb is involved, and odex/vdex/oat/cdex are not read.",
"No signature is verified: signer, rotation-lineage and source-stamp signatures are NOT cryptographically checked, and certificate chains, revocation and authenticity are NOT evaluated. Certificates and lineages are what the APK declares.",
"matches_apk and self_consistent only show that the declared content digest equals the digest of the current bytes. This is not a signature verification: anyone who changes the bytes can recompute the digest and write it into the signed data. SHA-256 identifies the exact bytes, not the producer.",
"v1 signers are what each signature block's PKCS#7 SignedData declares: its signatures, the .SF and MANIFEST.MF digests and entry coverage are NOT verified.",
"v1 signers are what each signature block's outer PKCS#7 SignedData declares. Signer unsigned attributes, including nested signatures and timestamps, are not decoded; signatures, the .SF and MANIFEST.MF digests and entry coverage are NOT verified.",
"Not read: v4 .apk.idsig, verity digests (matches_apk null), and the v1 X-Android-APK-Signed marker, so v1_only cannot distinguish a v1-only APK from one whose v2/v3 block was removed.",
];
+4 -1
View File
@@ -97,6 +97,9 @@ const signerInfoSchema = z.strictObject({
.regex(/^[0-9a-f]*$/)
.nullable(),
authenticated_attributes: z.boolean(),
// Optional for Evidence recorded before unsigned attributes were observed.
// Null means the field was absent; an array means it was present.
unsigned_attribute_oids: z.array(z.string()).nullable().optional(),
});
const pkcs7Schema = z.strictObject({
@@ -139,7 +142,7 @@ export const peCertificatesSchema = z.strictObject({
.nullable(),
entries: z.array(certificateEntrySchema),
coverage: z.strictObject({
status: z.literal("complete"),
status: z.enum(["complete", "partial"]),
examined_entries: uint,
certificates: uint,
}),
+18 -1
View File
@@ -445,6 +445,7 @@ export interface ParsedSigner {
readonly serial_hex: string | null;
readonly subject_key_identifier_hex: string | null;
readonly authenticated_attributes: boolean;
readonly unsigned_attribute_oids: readonly string[] | null;
}
const signerIdentifier = (
@@ -517,7 +518,22 @@ const parseSigner = (
if (signature.content.length === 0)
return fail("SignerInfo signature OCTET STRING is empty.");
cursor += 2;
if (children[cursor]?.tag === 0xa1) cursor += 1;
let unsignedAttributeOids: string[] | null = null;
const unsigned = children[cursor];
if (unsigned?.tag === 0xa1) {
unsignedAttributeOids = unsigned.children.map((attribute) => {
const fields = sequence(attribute, "SignerInfo unsigned attribute");
const oid = fields[0];
if (fields.length !== 2 || oid?.tag !== 0x06 || fields[1]?.tag !== 0x31)
return fail(
"SignerInfo unsigned attribute is missing its type or values.",
);
// OID text expands into decimal arcs and is retained in the result.
chargeDecodedName(names, oid.content.length * 4);
return decodeOid(oid.content);
});
cursor += 1;
}
if (cursor !== children.length)
return fail("SignerInfo has unexpected trailing fields.");
return {
@@ -527,6 +543,7 @@ const parseSigner = (
serial_hex,
subject_key_identifier_hex,
authenticated_attributes: authenticated,
unsigned_attribute_oids: unsignedAttributeOids,
};
};
+34 -1
View File
@@ -21,7 +21,10 @@ import {
PeCertificateAsn1Failure,
readAsn1,
} from "./PeCertificateAsn1.js";
import { parsePeCertificates } from "./PeCertificateParser.js";
import {
parsePeCertificates,
UNSIGNED_ATTRIBUTES_LIMITATION,
} from "./PeCertificateParser.js";
const defaults = inspectPeCertificatesInputSchema.parse({
path: "/artifacts/app.exe",
@@ -72,6 +75,36 @@ it("reads PE32 and PE32+ PKCS#7 certificate tables", () => {
}
});
it("reports unsigned attribute OIDs and incomplete nested coverage", () => {
const types = [
[1, 3, 6, 1, 4, 1, 311, 3, 3, 1],
[1, 3, 6, 1, 4, 1, 311, 2, 4, 1],
];
for (const plus of [false, true]) {
const report = peCertificatesSchema.parse(
parse(
peCertificateFixture(
plus,
pkcs7SignedDataFixture(undefined, undefined, undefined, types),
),
),
);
expect(
report.entries[0]?.pkcs7?.signers[0]?.unsigned_attribute_oids,
).toEqual(["1.3.6.1.4.1.311.3.3.1", "1.3.6.1.4.1.311.2.4.1"]);
expect(report.coverage).toMatchObject({
status: "partial",
certificates: 1,
});
expect(report.limitations).toContain(UNSIGNED_ATTRIBUTES_LIMITATION);
const control = parse(peCertificateFixture(plus));
expect(
control.entries[0]?.pkcs7?.signers[0]?.unsigned_attribute_oids,
).toBeNull();
expect(control.coverage.status).toBe("complete");
}
});
it("accepts only zero eight-byte alignment padding included in dwLength", () => {
for (const plus of [false, true]) {
const bytes = peCertificateFixture(plus, undefined, undefined, true);
+17 -4
View File
@@ -43,6 +43,9 @@ const LIMITATIONS = [
const TELETEX_LIMITATION =
"TeletexString values use OpenSSL's ISO-8859-1 compatibility mapping; this is not a full T.61 decoder.";
export const UNSIGNED_ATTRIBUTES_LIMITATION =
"Signer unsigned attribute contents are not decoded; nested signatures and timestamp token certificates and times may be omitted.";
const ABSENT_LIMITATIONS = [
"IMAGE_DIRECTORY_ENTRY_SECURITY is absent. That is not proof the image is unsigned in every catalog or overlay sense.",
"This workflow does not verify signatures, certificate chains, timestamps, or Authenticode digest coverage of image bytes.",
@@ -82,6 +85,7 @@ export const parsePeCertificates = (
let at = 0;
let examined = 0;
let teletexStringCompatibilityUsed = false;
let unsignedAttributesPresent = false;
while (at < table.length) {
// Alignment padding sits inside the gap before the next 8-byte boundary.
// Bytes that remain after that boundary are a short header, including
@@ -123,6 +127,9 @@ export const parsePeCertificates = (
const parsed = parsePkcs7SignedData(root);
teletexStringCompatibilityUsed ||=
parsed.teletexStringCompatibilityUsed;
unsignedAttributesPresent ||= parsed.signers.some(
(signer) => signer.unsigned_attribute_oids !== null,
);
pkcs7 = {
content_type_oid: parsed.content_type_oid,
content_type_name: oidName(parsed.content_type_oid),
@@ -166,6 +173,10 @@ export const parsePeCertificates = (
serial_hex: signer.serial_hex,
subject_key_identifier_hex: signer.subject_key_identifier_hex,
authenticated_attributes: signer.authenticated_attributes,
unsigned_attribute_oids:
signer.unsigned_attribute_oids === null
? null
: [...signer.unsigned_attribute_oids],
})),
};
} catch (cause) {
@@ -211,12 +222,14 @@ export const parsePeCertificates = (
},
entries,
coverage: {
status: "complete",
status: unsignedAttributesPresent ? "partial" : "complete",
examined_entries: examined,
certificates,
},
limitations: teletexStringCompatibilityUsed
? [...LIMITATIONS, TELETEX_LIMITATION]
: [...LIMITATIONS],
limitations: [
...LIMITATIONS,
...(teletexStringCompatibilityUsed ? [TELETEX_LIMITATION] : []),
...(unsignedAttributesPresent ? [UNSIGNED_ATTRIBUTES_LIMITATION] : []),
],
};
};
+13
View File
@@ -67,6 +67,7 @@ export const pkcs7SignedDataFixture = (
commonName = "REA Fixture",
organization = "REA Tests",
signature = octet(Buffer.from("sig")),
unsignedAttributes?: readonly (readonly number[])[],
): Buffer => {
const serial = Buffer.from([0x11, 0x22, 0x33, 0x44]);
const subject = name(commonName, organization);
@@ -94,6 +95,18 @@ export const pkcs7SignedDataFixture = (
algorithm(SHA256),
algorithm(SHA256_RSA),
signature,
...(unsignedAttributes === undefined
? []
: [
context(
1,
concat(
...unsignedAttributes.map((type) =>
seq(oid(type), set(octet(Buffer.from([1])))),
),
),
),
]),
);
const signedData = seq(
integer(1),
@@ -1,6 +1,7 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { UNSIGNED_ATTRIBUTES_LIMITATION } from "../../src/native/pe/PeCertificateParser.js";
import { toolContract } from "../../src/contracts/toolContracts.js";
import {
peCertificateFixture,
@@ -31,6 +32,32 @@ cliTest(
},
);
cliTest("reports unsigned attributes through CLI and MCP", async ({ cli }) => {
const root = await createTestTempDirectory("rea-pe-cert-unsigned-attrs-");
const path = join(root, "timestamped.exe");
const payload = pkcs7SignedDataFixture(undefined, undefined, undefined, [
[1, 3, 6, 1, 4, 1, 311, 3, 3, 1],
]);
await writeFile(path, peCertificateFixture(true, payload));
const result = await cli.run({
arguments: ["inspect-pe-certificates", path, "--json"],
});
expect(result.exitCode, result.stderr).toBe(0);
const call = await cli.sharedMcpCall!();
const response = await call("inspect_pe_certificates", { path });
expect(response.isError, JSON.stringify(response)).not.toBe(true);
for (const value of [result.json, response.structuredContent]) {
const report = toolContract("inspect_pe_certificates").outputSchema.parse(
value,
).normalized_result;
expect(report.coverage.status).toBe("partial");
expect(
report.entries[0]?.pkcs7?.signers[0]?.unsigned_attribute_oids,
).toEqual(["1.3.6.1.4.1.311.3.3.1"]);
expect(report.limitations).toContain(UNSIGNED_ATTRIBUTES_LIMITATION);
}
});
cliTest(
"inspects SignedData with an indefinite constructed ContentInfo envelope",
async ({ cli }) => {
+46 -1
View File
@@ -1,11 +1,16 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { Ajv2020 } from "ajv/dist/2020.js";
import type { AnySchema } from "ajv";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { createServer } from "../../../src/server/createServer.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { peCertificateFixture } from "../../../src/native/pe/PeCertificates.fixture.js";
import {
peCertificateFixture,
pkcs7SignedDataFixture,
} from "../../../src/native/pe/PeCertificates.fixture.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { mcpTest, type TestMcp } from "../../support/mcp/mcpFixture.js";
import { repeatedCertificateSignedData } from "../../fixtures/peCertificateAllowance.js";
@@ -18,6 +23,46 @@ const connect = async (mcp: TestMcp) => {
return { client, session };
};
mcpTest(
"advertised output schema accepts partial unsigned-attribute evidence",
async ({ mcp }) => {
const { client, session } = await connect(mcp);
try {
const advertised = (await client.listTools()).tools.find(
(tool) => tool.name === "inspect_pe_certificates",
);
if (advertised?.outputSchema === undefined)
throw new Error("PE certificates output schema is missing");
const schema = advertised.outputSchema as AnySchema;
const ajv = new Ajv2020({ strict: false, validateFormats: false });
expect(ajv.validateSchema(schema)).toBe(true);
const validate = ajv.compile(schema);
const root = await createTestTempDirectory("rea-pe-cert-schema-");
const path = join(root, "timestamped.exe");
await writeFile(
path,
peCertificateFixture(
false,
pkcs7SignedDataFixture(undefined, undefined, undefined, [
[1, 3, 6, 1, 4, 1, 311, 3, 3, 1],
]),
),
);
const result = await client.callTool({
name: "inspect_pe_certificates",
arguments: { path },
});
expect(result.isError).not.toBe(true);
expect(
validate(result.structuredContent),
JSON.stringify(validate.errors),
).toBe(true);
} finally {
await session.close();
}
},
);
mcpTest(
"advertises inspect_pe_certificates and reads PKCS#7 signer identity",
async ({ mcp }) => {