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
31 changes: 28 additions & 3 deletions scripts/inject-schema-constraints.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -664,13 +664,31 @@ function numericBounds(node) {
return Object.keys(descriptor).length ? descriptor : null;
}

// Annotation keywords carry no assertion, so a branch that merely labels itself
// is still a branch this recorder can model. profile.json titles every
// jwk_public_key branch ("EC keys carry crv, x, y" and so on), and treating the
// title as an unmodellable extra keyword disqualified all five.
const CONDITIONAL_BRANCH_ANNOTATIONS = new Set([
"title",
"description",
"$comment",
"examples",
"default",
"deprecated",
]);

function describeConditionalRule(branch, properties) {
const condition = branch?.if;
const consequence = branch?.then;
if (
!condition ||
!consequence ||
Object.keys(branch).some((key) => key !== "if" && key !== "then") ||
Object.keys(branch).some(
(key) =>
key !== "if" &&
key !== "then" &&
!CONDITIONAL_BRANCH_ANNOTATIONS.has(key)
) ||
Object.keys(condition).some(
(key) => key !== "properties" && key !== "required"
) ||
Expand Down Expand Up @@ -796,12 +814,19 @@ function recordConditionalRules(node, properties) {
for (const branch of branches) {
const rule = describeConditionalRule(branch, properties);
if (!rule) {
// A branch this recorder cannot model is skipped, not fatal. Dropping the
// whole list because one branch is unmodellable discarded the branches
// that ARE expressible: profile.json's jwk_public_key states five, two of
// them plain `required` consequences, and the three `alg` const branches
// took those two down with them. Every `if`/`then` only adds constraints,
// so keeping the expressible subset can only enforce more, never reject
// something the schema permits.
unsupported = true;
break;
continue;
}
rules.push(rule);
}
if (unsupported) rules.length = 0;
void unsupported;
rules.sort((left, right) =>
JSON.stringify(left).localeCompare(JSON.stringify(right))
);
Expand Down
101 changes: 92 additions & 9 deletions src/spec_generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1280,15 +1280,98 @@ export const McpToolCallSchema = z.object({
});
export type McpToolCall = z.infer<typeof McpToolCallSchema>;

export const EcKeysCarryCrvXYSchema = z.object({
alg: z.string().optional(),
crv: z.string().optional(),
kid: z.string(),
kty: z.string(),
use: z.string().optional(),
x: z.string().optional(),
y: z.string().optional(),
});
export const EcKeysCarryCrvXYSchema = z
.object({
alg: z.string().optional(),
crv: z.string().optional(),
kid: z.string(),
kty: z.string(),
use: z.string().optional(),
x: z.string().optional(),
y: z.string().optional(),
})
.superRefine((value, ctx) => {
for (const rule of [
{
kind: "required",
discriminator: "kty",
values: ["EC"],
negated: false,
required: ["crv", "x", "y"],
field: null,
format: null,
target: null,
minimum: null,
maximum: null,
exclusiveMinimum: null,
exclusiveMaximum: null,
},
{
kind: "required",
discriminator: "kty",
values: ["OKP"],
negated: false,
required: ["crv", "x"],
field: null,
format: null,
target: null,
minimum: null,
maximum: null,
exclusiveMinimum: null,
exclusiveMaximum: null,
},
]) {
const record = value as Record<string, unknown>;
const discriminatorVal = record[rule.discriminator];
if (discriminatorVal === undefined) continue;
const matches = (rule.values as readonly unknown[]).includes(
discriminatorVal
);
if (rule.negated ? matches : !matches) continue;
if (rule.kind === "required") {
for (const field of rule.required) {
if (!(field in record))
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [field],
message: "Field is required by a conditional constraint",
});
}
continue;
}
if (rule.kind === "format") {
const field = rule.field;
const fieldValue = field === null ? undefined : record[field];
if (rule.format === "uri" && typeof fieldValue === "string") {
try {
new URL(fieldValue);
} catch {
if (field !== null)
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [field],
message: "Value must be a valid URI",
});
}
}
continue;
}
if (rule.target === null) continue;
const target = record[rule.target];
if (typeof target !== "number") continue;
const invalid =
(rule.minimum !== null && target < rule.minimum) ||
(rule.maximum !== null && target > rule.maximum) ||
(rule.exclusiveMinimum !== null && target <= rule.exclusiveMinimum) ||
(rule.exclusiveMaximum !== null && target >= rule.exclusiveMaximum);
if (invalid)
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [rule.target],
message: "Value violates a conditional numeric constraint",
});
}
});
export type EcKeysCarryCrvXY = z.infer<typeof EcKeysCarryCrvXYSchema>;

export const AdjustmentLineItemClassSchema = z.object({
Expand Down
65 changes: 65 additions & 0 deletions tests/profile-key-conditionals.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Fidelity tests for the conditional rules on a published signing key.
//
// profile.json states five conditional branches on `jwk_public_key`. Two are
// plain `required` consequences that this repo can already model: an EC key
// carries crv, x and y, and an OKP key carries crv and x. Every branch also
// carries a `title`, and the branch shape check treated that annotation as an
// unmodellable extra keyword, so all five were discarded and a key declaring
// `kty: "EC"` with no curve and no coordinates parsed cleanly.
//
// The remaining three branches pair a curve with an algorithm through a
// `properties` consequence, which the recorder does not model. Those are still
// not enforced, so a P-256 key declaring ES384 is still accepted here.

const { test } = require("node:test");
const assert = require("node:assert/strict");

const { EcKeysCarryCrvXYSchema } = require("./.dist/spec_generated.js");

const accepts = (schema, value) => schema.safeParse(value).success === true;
const rejects = (schema, value) => schema.safeParse(value).success === false;

const EC = {
kid: "key-1",
kty: "EC",
crv: "P-256",
alg: "ES256",
x: "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
y: "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0",
};
const OKP = {
kid: "key-2",
kty: "OKP",
crv: "Ed25519",
alg: "EdDSA",
x: "11qY",
};

test("an EC key must carry crv, x and y", () => {
assert.ok(accepts(EcKeysCarryCrvXYSchema, EC));
assert.ok(rejects(EcKeysCarryCrvXYSchema, { kid: "key-1", kty: "EC" }));
const { crv, ...noCrv } = EC;
assert.ok(rejects(EcKeysCarryCrvXYSchema, noCrv));
const { y, ...noY } = EC;
assert.ok(rejects(EcKeysCarryCrvXYSchema, noY));
});

test("an OKP key must carry crv and x", () => {
assert.ok(accepts(EcKeysCarryCrvXYSchema, OKP));
assert.ok(rejects(EcKeysCarryCrvXYSchema, { kid: "key-2", kty: "OKP" }));
const { x, ...noX } = OKP;
assert.ok(rejects(EcKeysCarryCrvXYSchema, noX));
});

test("the conditional applies only to the matching key type", () => {
// An unrelated key type carries neither obligation, so the rule must not
// fire and turn every other key into an error.
assert.ok(
accepts(EcKeysCarryCrvXYSchema, { kid: "key-3", kty: "oct", alg: "HS256" })
);
});

test("the required members the base schema already states still hold", () => {
assert.ok(rejects(EcKeysCarryCrvXYSchema, { kid: "key-1" }));
assert.ok(rejects(EcKeysCarryCrvXYSchema, { kty: "EC" }));
});
Loading