diff --git a/tests/test_trust_record_verify_citations_and_platform.py b/tests/test_trust_record_verify_citations_and_platform.py index 1392ce07..cc660f73 100644 --- a/tests/test_trust_record_verify_citations_and_platform.py +++ b/tests/test_trust_record_verify_citations_and_platform.py @@ -177,6 +177,50 @@ def ts_results() -> dict[str, Any]: out.checks.badPlatformAppraiser = await codeOf(() => trace.verifyRecord({}, { platformAppraiser: 123 }) ); +out.checks.nonObjectParentRecordHash = await codeOf(() => + trace.parentRecordHash(123) +); +out.checks.nonObjectParentRecordHashBadAlg = await codeOf(() => + trace.parentRecordHash(123, "md5") +); +out.checks.nonObjectParentEmptyChild = await codeOf(() => + trace.verifyDelegationLink({}, 123) +); +out.checks.nonObjectParentDelegationLink = await codeOf(() => + trace.verifyDelegationLink( + { + delegation: { + parent_record_hash: "sha256:" + "a".repeat(64), + credential_id: "c1", + }, + }, + 123, + ) +); +out.checks.nonObjectBundleDigest = await codeOf(() => + trace.bundleDigest(123) +); +const rev01 = JSON.parse( + fs.readFileSync( + path.join(root, "examples", "revocation-bundle", "01-fresh-well-inside-both-bounds.json"), + "utf8", + ) +); +const nonPlainCheck = await trace.checkRevocationBundle( + Object.assign(new (class Bundle {})(), rev01.context.bundle), + { + trustedKeyIdentifiers: ["issuer-key-2026"], + trustedBundleKeys: rev01.context.trusted_bundle_keys, + now: rev01.context.now, + maxBundleAgeSeconds: rev01.context.max_bundle_age_seconds, + maxFutureSkewSeconds: rev01.context.max_future_skew_seconds, + }, +); +out.checks.nonPlainRevocationBundle = [ + nonPlainCheck.outcome, + nonPlainCheck.cause, + nonPlainCheck.evidence?.path ?? null, +]; out.checks.nonStringMeasurements = []; const badRuntimes = [ {}, @@ -233,3 +277,22 @@ def test_ts_non_string_or_empty_runtime_measurement_is_field_absent( assert ts_results["checks"]["nonStringMeasurements"] == [ ["not_attempted", "field_absent"] ] * 6 + + +def test_ts_non_object_inputs_rejected_in_delegation_and_bundle_digest( + ts_results: dict[str, Any], +) -> None: + assert ts_results["checks"]["nonObjectParentRecordHash"] == "record_not_object" + assert ( + ts_results["checks"]["nonObjectParentRecordHashBadAlg"] + == "delegation_algorithm_unsupported" + ) + assert ts_results["checks"]["nonObjectParentEmptyChild"] == "record_not_object" + assert ts_results["checks"]["nonObjectParentDelegationLink"] == "record_not_object" + assert ts_results["checks"]["nonObjectBundleDigest"] == "invalid_argument" + assert ts_results["checks"]["nonPlainRevocationBundle"] == [ + "unverified_for_revocation", + "bundle_malformed", + "/", + ] + diff --git a/trust-record-verify/src/delegation.ts b/trust-record-verify/src/delegation.ts index 92a58622..5b703dcb 100644 --- a/trust-record-verify/src/delegation.ts +++ b/trust-record-verify/src/delegation.ts @@ -53,6 +53,9 @@ export async function parentRecordHash( `unsupported chain digest algorithm ${typeof algorithm === "string" ? JSON.stringify(algorithm) : typeof algorithm}`, ); } + if (!isPlainObject(record)) { + fail("record_not_object", "a Trust Record is a JSON object"); + } return `${algorithm}:${hex(await digest(WEBCRYPTO_NAME[algorithm], canonicalize(record)))}`; } @@ -99,7 +102,7 @@ export async function verifyDelegationLink( fail("invalid_argument", "supportedDigestAlgorithms must be an array of algorithm names"); } const supported: readonly string[] = configured ?? DELEGATION_DIGEST_ALGORITHMS; - if (!isPlainObject(child)) { + if (!isPlainObject(child) || !isPlainObject(parent)) { fail("record_not_object", "a Trust Record is a JSON object"); } const delegation = own(child, "delegation"); diff --git a/trust-record-verify/src/revocation.ts b/trust-record-verify/src/revocation.ts index 6aa851e4..fa35fe94 100644 --- a/trust-record-verify/src/revocation.ts +++ b/trust-record-verify/src/revocation.ts @@ -80,6 +80,9 @@ function unverified(cause: RevocationCause, evidence: Record): /** The bundle's identity: `sha256:` and the hex SHA-256 of its RFC 8785 form, signature included. */ export async function bundleDigest(bundle: unknown): Promise { + if (!isPlainObject(bundle)) { + fail("invalid_argument", "bundle must be a JSON object"); + } return `sha256:${hex(await digest("SHA-256", canonicalize(bundle)))}`; } @@ -140,6 +143,9 @@ export async function checkRevocationBundle( } // 1. Shape. The first violation by location, so the evidence names one place. + if (!isPlainObject(bundle)) { + return unverified("bundle_malformed", { path: "/", error: "the bundle is not a plain JSON object" }); + } const violations = bundleSchemaViolations(bundle); if (violations.length > 0) { const first = violations[0] as { path: readonly string[]; message: string }; diff --git a/trust-record-verify/tests/units.test.mjs b/trust-record-verify/tests/units.test.mjs index 40722d38..be1e9432 100644 --- a/trust-record-verify/tests/units.test.mjs +++ b/trust-record-verify/tests/units.test.mjs @@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url"; import vm from "node:vm"; import { + bundleDigest, canonicalJson, checkRevocationBundle, decodeBase64url, @@ -115,6 +116,60 @@ test("verifyDelegationLink takes a plain options object, as verifyRecord does", } }); +test("parentRecordHash, verifyDelegationLink and bundleDigest refuse non-object inputs", async () => { + const validChild = { + delegation: { + parent_record_hash: "sha256:a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3", + credential_id: "cred-1", + }, + }; + for (const bad of [null, undefined, 123, true, false, "record", [1, 2], new Date(0)]) { + await rejects(parentRecordHash(bad), "record_not_object"); + await rejects(verifyDelegationLink(bad, {}), "record_not_object"); + await rejects(verifyDelegationLink({}, bad), "record_not_object"); + await rejects(verifyDelegationLink(validChild, bad), "record_not_object"); + await rejects(bundleDigest(bad), "invalid_argument"); + } + await rejects(parentRecordHash(123, "md5"), "delegation_algorithm_unsupported"); +}); + +test("checkRevocationBundle and verifyRecord report non-plain bundle objects as bundle_malformed", async () => { + const examples = join(dirname(dirname(dirname(fileURLToPath(import.meta.url)))), "examples", "revocation-bundle"); + const v01 = JSON.parse(readFileSync(join(examples, "01-fresh-well-inside-both-bounds.json"), "utf8")); + const v20 = JSON.parse(readFileSync(join(examples, "20-malformed-statement-on-another-log.json"), "utf8")); + + class Bundle {} + for (const vec of [v01, v20]) { + const opts = { + trustedKeyIdentifiers: ["issuer-key-2026"], + trustedBundleKeys: vec.context.trusted_bundle_keys, + now: vec.context.now, + maxBundleAgeSeconds: vec.context.max_bundle_age_seconds, + maxFutureSkewSeconds: vec.context.max_future_skew_seconds, + }; + for (const nonPlain of [ + Object.assign(new Bundle(), vec.context.bundle), + Object.assign(Object.create({}), vec.context.bundle), + ]) { + const check = await checkRevocationBundle(nonPlain, opts); + assert.equal(check.outcome, "unverified_for_revocation"); + assert.equal(check.cause, "bundle_malformed"); + assert.equal(check.evidence.path, "/"); + + const res = await verifyRecord(vec.records[0], { + trustedKey: vec.context.trusted_key, + now: vec.context.now, + maxAgeSeconds: null, + revocationBundle: nonPlain, + trustedBundleKeys: vec.context.trusted_bundle_keys, + }); + assert.equal(res.revocation.outcome, "unverified_for_revocation"); + assert.equal(res.revocation.cause, "bundle_malformed"); + assert.equal(res.revocation.evidence.path, "/"); + } + } +}); + test("a plain object made in another realm is a plain object", async () => { // `{}` from another realm inherits from that realm's Object.prototype. The // options are accepted, so the call goes on to the child record and reports