Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9811ab7
feat(spec): checkProfileRegistration — what the gateway profile-regis…
LamaSu Sep 24, 2026
eda1b79
merge: update #384 with #363 (39dfd450)
LamaSu Sep 28, 2026
bffd1a1
merge: update #384 with #363 round 2 (48a69252)
LamaSu Sep 29, 2026
168ec19
test(spec): registration follows #363 round 2 — split pins are regist…
LamaSu Sep 29, 2026
cc3358e
merge: update #384 with #363 (9d29bde6, tree identical to 48a69252)
LamaSu Sep 29, 2026
29078d9
merge: update #384 with #363 round 3 (e4478ed9)
LamaSu Sep 29, 2026
492bd00
fix(spec): profile registration checks, digests and returns one plain…
LamaSu Sep 29, 2026
ace99a7
merge: update #384 stack with feat/sensors-profile-admission (f12cbdb…
LamaSu Sep 30, 2026
c15abe9
merge: update #384 stack with feat/sensors-profile-admission (null-pr…
LamaSu Sep 30, 2026
27c4d11
merge: update #384 stack with feat/sensors-profile-admission (astra p…
LamaSu Oct 3, 2026
31bde51
merge: update #384 stack with feat/sensors-profile-admission (49cbef6…
LamaSu Oct 3, 2026
760d84e
test(spec): registration refuses a lying getter without reading it, p…
LamaSu Oct 3, 2026
a7ceac7
merge: update #384 stack with feat/sensors-profile-admission (8c1706f…
LamaSu Oct 3, 2026
73aa9ee
merge: #363's head (9b5c8592) into #384: #336's plain-data fix for as…
LamaSu Oct 3, 2026
64be17e
merge: #363's head (45f2c1f1) into #384: #336's round 8 for astra pac…
LamaSu Oct 3, 2026
46c743f
merge: #363's merge-up (ca6986bf, carrying master cd9d8770) into #384…
LamaSu Oct 4, 2026
1d2eb6e
merge: #363's migration (cdff0552: #345's trust-domain rule, steward …
LamaSu Oct 4, 2026
cb9d9e2
merge: #363's pack-271 fix (e6fb2d24: the pinned signer snapshot, the…
LamaSu Oct 4, 2026
8ac9954
merge: #363's round 13 (e384d5c4) into #384
LamaSu Oct 4, 2026
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
178 changes: 178 additions & 0 deletions packages/spec/src/__tests__/profile-registration.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof checkProfileRegistration>) => (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<string, unknown>;
(p.measurement as Record<string, unknown>).sampling = { minSamples: Number.NaN };
expect(codes(checkProfileRegistration(request(p)))).toEqual(["profile-invalid"]);
});
});
3 changes: 3 additions & 0 deletions packages/spec/src/evidence/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<key> (D2 signer, registry-held, never a compromised key).
export * from "./principal-id.js";
Expand Down
113 changes: 113 additions & 0 deletions packages/spec/src/evidence/profile-registration.ts
Original file line number Diff line number Diff line change
@@ -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: `<root>: 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 || "<root>"}: ${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 };
}
Loading