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:
1 parent
0386efd1e1
commit
33d6975749
11 files changed
+183
-9
No files matched your search
@@ -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.",
|
||||
];
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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] : []),
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in new issue
Block a user