Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions tests/test_trust_record_verify_citations_and_platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{},
Expand Down Expand Up @@ -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",
"/",
]

5 changes: 4 additions & 1 deletion trust-record-verify/src/delegation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)))}`;
}

Expand Down Expand Up @@ -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");
Expand Down
6 changes: 6 additions & 0 deletions trust-record-verify/src/revocation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ function unverified(cause: RevocationCause, evidence: Record<string, unknown>):

/** The bundle's identity: `sha256:` and the hex SHA-256 of its RFC 8785 form, signature included. */
export async function bundleDigest(bundle: unknown): Promise<string> {
if (!isPlainObject(bundle)) {
fail("invalid_argument", "bundle must be a JSON object");
}
return `sha256:${hex(await digest("SHA-256", canonicalize(bundle)))}`;
}

Expand Down Expand Up @@ -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 };
Expand Down
55 changes: 55 additions & 0 deletions trust-record-verify/tests/units.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url";
import vm from "node:vm";

import {
bundleDigest,
canonicalJson,
checkRevocationBundle,
decodeBase64url,
Expand Down Expand Up @@ -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
Expand Down
Loading