diff --git a/packages/spec/src/__tests__/profile-registration.test.ts b/packages/spec/src/__tests__/profile-registration.test.ts new file mode 100644 index 000000000..234ae52a2 --- /dev/null +++ b/packages/spec/src/__tests__/profile-registration.test.ts @@ -0,0 +1,178 @@ +/** + * checkProfileRegistration — what the gateway's append-only profile + * registration route (bus #2621) runs before inserting a row. + */ +import { describe, it, expect } from "vitest"; + +import { checkProfileRegistration } from "../evidence/profile-registration.js"; +import { computeMeasurementProfileDigest, type MeasurementProfileV1 } from "../evidence/measurement-profile.js"; + +const CAMERA = "dev-camera"; +const VERSION = "PhotoCameraAdapter-1.0.0"; + +function cameraProfile(): MeasurementProfileV1 { + return { + profileVersion: 1, + profileId: "pcc://profiles/test/inspected-page/v1", + outcome: { + capabilityType: "document-printing", + statement: "The page was printed and a separate camera inspected it.", + objectIdentity: { kind: "documentHash", value: "sha256:" + "c".repeat(64) }, + }, + device: { + deviceId: CAMERA, + kind: "camera", + adapterType: "photo", + permittedAdapterVersions: [VERSION], + permittedFirmwareVersions: [VERSION], + }, + measurement: { method: "optical-capture", quantity: "printed-page-image", unit: "none", sampling: { minSamples: 1 } }, + capture: { startCondition: "execution_completed", endCondition: "open", coverage: { policy: "one-shot", minFraction: 1 } }, + calibration: { required: false }, + interpretation: { evidenceTypeIds: ["capture.photo_nonced"], acceptanceLevel: "inspected_output", onDeviceFailure: "reject" }, + simulationProhibited: true, + witnesses: { requiredRoles: [], independentOfClaimant: false }, + onMissingData: "reject", + onContradiction: "reject", + }; +} + +const request = (profile: unknown, over: Partial<{ capabilityType: string; deviceId: string; claimedDigest: string }> = {}) => ({ + capabilityType: "document-printing", + deviceId: CAMERA, + profile, + ...over, +}); + +const codes = (r: ReturnType) => (r.ok ? [] : r.problems.map((p) => p.code)); + +describe("profile registration — accepts a registrable profile", () => { + it("disjoint adapter and firmware pins are registrable: each pin is checked against its own evidence field", () => { + const p = cameraProfile(); + p.device.permittedFirmwareVersions = ["cam-fw-2.1.0"]; + expect(checkProfileRegistration(request(p)).ok).toBe(true); + }); + + it("returns the server-computed digest", () => { + const p = cameraProfile(); + const r = checkProfileRegistration(request(p)); + expect(r.ok).toBe(true); + if (r.ok) expect(r.profileDigest).toBe(computeMeasurementProfileDigest(p)); + }); + + it("accepts a client digest only when it equals the server's", () => { + const p = cameraProfile(); + expect(checkProfileRegistration(request(p, { claimedDigest: computeMeasurementProfileDigest(p) })).ok).toBe(true); + }); +}); + +describe("profile registration — refuses what could never govern the device's evidence", () => { + it("an invalid profile", () => { + const p = { ...cameraProfile(), simulationProhibited: false }; + expect(codes(checkProfileRegistration(request(p)))).toEqual(["profile-invalid"]); + }); + + it("a non-object never throws", () => { + expect(codes(checkProfileRegistration(request("not a profile")))).toEqual(["profile-invalid"]); + expect(codes(checkProfileRegistration(request(null)))).toEqual(["profile-invalid"]); + }); + + it("a profile naming another device than the row it would be stored under", () => { + expect(codes(checkProfileRegistration(request(cameraProfile(), { deviceId: "dev-other" })))).toEqual(["device-mismatch"]); + }); + + it("a profile for another capability type", () => { + expect(codes(checkProfileRegistration(request(cameraProfile(), { capabilityType: "cnc-milling" })))).toEqual([ + "capability-type-mismatch", + ]); + }); + + it("a profile with a term admission cannot evaluate (refused now, not discovered at settlement)", () => { + const p = cameraProfile(); + p.measurement.tolerance = { comparator: ">=", target: 0.9 }; + const r = checkProfileRegistration(request(p)); + expect(codes(r)).toEqual(["unverifiable-term"]); + }); + + it("a version pin written as a pattern (pins are exact strings)", () => { + const p = cameraProfile(); + p.device.permittedFirmwareVersions = ["*-unpinned-pilot"]; + const r = checkProfileRegistration(request(p)); + expect(codes(r)).toEqual(["unverifiable-term"]); + expect(r.ok ? "" : r.problems[0]!.detail).toContain("exact strings"); + }); + + it("a device kind no evidence source can carry", () => { + const p = cameraProfile(); + p.device.kind = "machine"; + expect(codes(checkProfileRegistration(request(p)))).toEqual(["unverifiable-term"]); + }); + + it("a primitive id that is not active in the vocabulary", () => { + const p = cameraProfile(); + p.interpretation.evidenceTypeIds = ["capture.no_such_primitive"]; + expect(codes(checkProfileRegistration(request(p)))).toEqual(["unverifiable-term"]); + }); + + it("a profile with an unknown term is invalid, so it can never be committed", () => { + const p = { ...cameraProfile(), extraTerm: "never evaluated" }; + expect(codes(checkProfileRegistration(request(p)))).toEqual(["profile-invalid"]); + }); + + it("a client digest that is not the digest of the submitted profile", () => { + const p = cameraProfile(); + const other = cameraProfile(); + other.measurement.sampling.minSamples = 2; + expect(codes(checkProfileRegistration(request(p, { claimedDigest: computeMeasurementProfileDigest(other) })))).toEqual([ + "digest-mismatch", + ]); + }); + + it("a client digest in the sha256: evidence-event family", () => { + const p = cameraProfile(); + const tagged = `sha256:${computeMeasurementProfileDigest(p).slice(2)}`; + expect(codes(checkProfileRegistration(request(p, { claimedDigest: tagged })))).toEqual(["digest-mismatch"]); + }); + + it("reports every problem at once, so an operator can fix the profile in one pass", () => { + const p = cameraProfile(); + p.measurement.tolerance = { comparator: ">=", target: 0.9 }; + const r = checkProfileRegistration(request(p, { deviceId: "dev-other", capabilityType: "cnc-milling", claimedDigest: "0x" + "0".repeat(64) })); + expect(codes(r)).toEqual(["device-mismatch", "capability-type-mismatch", "unverifiable-term", "digest-mismatch"]); + }); +}); + +describe("profile registration — checks, digest and stored row are one copy (astra pack 39 discipline)", () => { + it("the profile returned for storage is a copy, not the caller's object, and later mutation cannot reach it", () => { + const p = cameraProfile(); + const r = checkProfileRegistration(request(p)); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.profile).not.toBe(p); + p.device.deviceId = "dev-mutated-after-check"; + expect(r.profile.device.deviceId).toBe(CAMERA); + expect(r.profileDigest).toBe(computeMeasurementProfileDigest(cameraProfile())); + }); + + // A getter could answer the check and the stored row differently, so the + // plain-data boundary refuses it without reading it (astra pack 154; it was + // once read exactly once, which still ran code supplied with the data). + it("a getter that could lie after the first read is refused, never read", () => { + const p = cameraProfile(); + let reads = 0; + Object.defineProperty(p.device, "deviceId", { + enumerable: true, + configurable: true, + get: () => (reads++ === 0 ? CAMERA : "dev-other"), + }); + const r = checkProfileRegistration(request(p)); + expect(codes(r)).toEqual(["profile-invalid"]); + expect(reads).toBe(0); + }); + + it("a request JSON cannot carry is refused as profile-invalid, never coerced", () => { + const p = cameraProfile() as unknown as Record; + (p.measurement as Record).sampling = { minSamples: Number.NaN }; + expect(codes(checkProfileRegistration(request(p)))).toEqual(["profile-invalid"]); + }); +}); diff --git a/packages/spec/src/evidence/index.ts b/packages/spec/src/evidence/index.ts index 1d459b22b..7f8add4b9 100644 --- a/packages/spec/src/evidence/index.ts +++ b/packages/spec/src/evidence/index.ts @@ -44,6 +44,9 @@ export * from "./measurement-profile.js"; // profileAdmitsBundle — does authenticated, bound evidence satisfy the committed // MeasurementProfile (level, device, version, window, samples, simulation)? export * from "./profile-admission.js"; +// checkProfileRegistration — may a MeasurementProfile be registered for a +// (capability, device)? Run by the gateway registration route. +export * from "./profile-registration.js"; // Principal ids for FinalMilestonePackageV2: operator = CAIP-10 (D1 signer), // device = ed25519:0x (D2 signer, registry-held, never a compromised key). export * from "./principal-id.js"; diff --git a/packages/spec/src/evidence/profile-registration.ts b/packages/spec/src/evidence/profile-registration.ts new file mode 100644 index 000000000..801557aab --- /dev/null +++ b/packages/spec/src/evidence/profile-registration.ts @@ -0,0 +1,113 @@ +/** + * checkProfileRegistration — may this MeasurementProfile be registered for a + * (capability, device)? + * + * pcc-gateway stores an operator's concrete profile in its own append-only + * table keyed by (capabilityId, deviceId) (bus #2621). The accepted-plan + * compiler later takes the CURRENT row's digest from the server-re-read + * provider snapshot (composition, bus #2238). This check is what the + * registration route runs before inserting a row. The route owns + * authentication, the kernel-ownership predicate and storage; this owns what + * makes a profile registrable: + * + * - `profile-invalid`: it fails `validateMeasurementProfile`; + * - `device-mismatch`: it names a different device from the row it would be + * stored under, so evidence from the registered device could never qualify; + * - `capability-type-mismatch`: its outcome is for another capability type; + * - `unverifiable-term`: admission cannot evaluate one of its terms, so every + * job under it would fail closed. Refused here rather than discovered at + * settlement; + * - `digest-mismatch`: the client sent a digest that is not the digest of + * the profile it sent. + * + * Every check runs on one plain-data copy of the request (`plainDataCopy`: + * each field read exactly once), and the profile returned for storage is that + * copy, so what is checked, digested and stored is one thing. The digest is + * always computed here, from that copy. A client-supplied digest is only ever + * compared, never stored or trusted. + * Every problem is reported, not just the first, so an operator can fix a + * profile in one pass. + */ + +import { + computeMeasurementProfileDigest, + plainDataCopy, + validateMeasurementProfile, + type MeasurementProfileDigest, + type MeasurementProfileV1, +} from "./measurement-profile.js"; +import { unverifiableProfileTerms } from "./profile-admission.js"; + +export type ProfileRegistrationCode = + | "profile-invalid" + | "device-mismatch" + | "capability-type-mismatch" + | "unverifiable-term" + | "digest-mismatch"; + +export interface ProfileRegistrationProblem { + code: ProfileRegistrationCode; + detail: string; +} + +export interface ProfileRegistrationRequest { + /** The type of the capability the row belongs to (from the capability record, not the request). */ + capabilityType: string; + /** The device the row is keyed by. */ + deviceId: string; + /** The profile as submitted, unvalidated. */ + profile: unknown; + /** A digest the client computed, if it sent one. Compared, never trusted. */ + claimedDigest?: string; +} + +export type ProfileRegistrationResult = + | { ok: true; profile: MeasurementProfileV1; profileDigest: MeasurementProfileDigest } + | { ok: false; problems: ProfileRegistrationProblem[] }; + +/** Never throws. */ +export function checkProfileRegistration(input: ProfileRegistrationRequest): ProfileRegistrationResult { + const copy = plainDataCopy(input); + if (!copy.ok || typeof copy.value !== "object" || copy.value === null) { + return { + ok: false, + problems: [{ code: "profile-invalid", detail: `: the request is not plain JSON data (${copy.ok ? "not an object" : copy.reason})` }], + }; + } + const request = copy.value as ProfileRegistrationRequest; + const violations = validateMeasurementProfile(request.profile); + if (violations.length > 0) { + return { + ok: false, + problems: violations.map((v) => ({ code: "profile-invalid" as const, detail: `${v.path || ""}: ${v.message}` })), + }; + } + const profile = request.profile as MeasurementProfileV1; + const problems: ProfileRegistrationProblem[] = []; + + if (profile.device.deviceId !== request.deviceId) { + problems.push({ + code: "device-mismatch", + detail: `the profile names device ${JSON.stringify(profile.device.deviceId)} but would be registered for ${JSON.stringify(request.deviceId)}`, + }); + } + if (profile.outcome.capabilityType !== request.capabilityType) { + problems.push({ + code: "capability-type-mismatch", + detail: `the profile is for ${JSON.stringify(profile.outcome.capabilityType)} but the capability is ${JSON.stringify(request.capabilityType)}`, + }); + } + for (const term of unverifiableProfileTerms(profile)) { + problems.push({ code: "unverifiable-term", detail: term }); + } + + const profileDigest = computeMeasurementProfileDigest(profile); + if (request.claimedDigest !== undefined && request.claimedDigest !== profileDigest) { + problems.push({ + code: "digest-mismatch", + detail: `the client sent ${JSON.stringify(request.claimedDigest)}; the submitted profile digests to ${profileDigest}`, + }); + } + + return problems.length > 0 ? { ok: false, problems } : { ok: true, profile, profileDigest }; +}