Skip to content
Merged
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
119 changes: 113 additions & 6 deletions apps/server/src/provider/prime/PrimeAgentDistributionVerifier.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
PRIME_HIGH_WATER_FILE,
PRIME_PREVIEW_MANIFEST,
PRIME_PREVIEW_WORKFLOW,
PRIME_PUBLICATION_POLICY,
PRIME_PUBLICATION_POLICIES,
PRIME_PUBLICATION_SCHEMA_SOURCE,
PRIME_RECEIPT_FILE,
PRIME_RECEIPT_KEY_FILE,
Expand Down Expand Up @@ -62,6 +62,7 @@ const VERSION = "1.0.0";

function syntheticPublication(
channel: "preview" | "stable" = "preview",
policies = { preview: 1, build: 1, promotion: 1 },
): PrimePublicationFixture & { readonly verified: VerifiedPrimePublication } {
const artifactBytes = new Map([
[`pylon-prime-agent-${VERSION}.tgz`, Buffer.from("synthetic-root-tarball-v1")],
Expand Down Expand Up @@ -116,7 +117,7 @@ function syntheticPublication(
schemaVersion: 1,
channel: "preview",
repository: PRIME_DISTRIBUTION_REPOSITORY_URL,
publicationPolicyRevision: 1,
publicationPolicyRevision: policies.preview,
sequenceEpoch: 1,
sequence: 7,
workflowRunId: "9001",
Expand Down Expand Up @@ -155,7 +156,7 @@ function syntheticPublication(
previewTag: BUILD_ID,
id: BUILD_ID,
recipeRevision: 1,
publicationPolicyRevision: 1,
publicationPolicyRevision: policies.build,
source: releaseManifest.source,
releaseManifest: {
file: PRIME_RELEASE_MANIFEST,
Expand All @@ -171,7 +172,7 @@ function syntheticPublication(
kind: "promote",
policyCommit: POLICY_COMMIT,
policyTree: POLICY_TREE,
publicationPolicyRevision: 1,
publicationPolicyRevision: policies.promotion,
},
revocations: [],
};
Expand Down Expand Up @@ -269,14 +270,15 @@ function githubAsset(id: number, name: string, url: string) {
function makeNetworkHarness(input: {
readonly channel: "preview" | "stable";
readonly candidates: number;
readonly fixture?: ReturnType<typeof syntheticPublication>;
readonly key: string;
readonly now?: () => number;
readonly failureTtlMs?: number;
readonly rateLimitTtlMs?: number;
readonly refreshReuseMs?: number;
readonly failListOnceWith403?: boolean;
}) {
const fixture = syntheticPublication(input.channel);
const fixture = input.fixture ?? syntheticPublication(input.channel);
const releaseManifest = JSON.parse(fixture.releaseManifestBytes.toString("utf8")) as {
assets: ReadonlyArray<{ readonly file: string }>;
};
Expand Down Expand Up @@ -462,7 +464,7 @@ describe("Pylon Prime publication verification", () => {
commit: "f4d9ef03b529faf2e07031c8b7cd703363316ae5",
tree: "b9a14b389aa64f54527008fb4d6119a7c57c2b58",
});
expect(PRIME_PUBLICATION_POLICY).toMatchObject({
expect(PRIME_PUBLICATION_POLICIES[0]).toMatchObject({
publicationPolicyRevision: 1,
previewWorkflowSha256: "e790a5da7063bd40fbd886e84945c3200291194fdbd5b002079349e45356a41d",
stableWorkflowSha256: "dfcecdf6b58f143f9b7a543eadd124c190350ae29ac9eadccb907f1398b0958a",
Expand All @@ -477,6 +479,111 @@ describe("Pylon Prime publication verification", () => {
);
});

it("retains immutable historical and current workflow policies", () => {
expect(Object.isFrozen(PRIME_PUBLICATION_POLICIES)).toBe(true);
expect(PRIME_PUBLICATION_POLICIES.every(Object.isFrozen)).toBe(true);
expect(PRIME_PUBLICATION_POLICIES).toEqual([
{
publicationPolicyRevision: 1,
previewWorkflowPath: PRIME_PREVIEW_WORKFLOW,
previewWorkflowSha256: "e790a5da7063bd40fbd886e84945c3200291194fdbd5b002079349e45356a41d",
stableWorkflowPath: PRIME_STABLE_WORKFLOW,
stableWorkflowSha256: "dfcecdf6b58f143f9b7a543eadd124c190350ae29ac9eadccb907f1398b0958a",
},
{
publicationPolicyRevision: 2,
previewWorkflowPath: PRIME_PREVIEW_WORKFLOW,
previewWorkflowSha256: "9f4e3f38fb0bdb9c11662310c5369fb792765a3090e0f74b0ec0b34127b43ed8",
stableWorkflowPath: PRIME_STABLE_WORKFLOW,
stableWorkflowSha256: "0f04d1f55f54312d933087d88de6883e8408bb0cd9f060d3b5851d710698b1af",
},
]);
});

it.each([
{ preview: 1, build: 1, promotion: 1 },
{ preview: 2, build: 2, promotion: 2 },
{ preview: 1, build: 1, promotion: 2 },
])("verifies independent build and promotion policies: %j", async (policies) => {
const fixture = syntheticPublication("stable", policies);
const verifySourcePolicy = vi.fn(validVerification.verifySourcePolicy);
await expect(
verifyPrimePublicationFixture(fixture, { ...validVerification, verifySourcePolicy }),
).resolves.toEqual(fixture.verified);
expect(verifySourcePolicy.mock.calls).toEqual([
[
{
commit: SOURCE_COMMIT,
tree: SOURCE_TREE,
workflow: PRIME_PREVIEW_WORKFLOW,
publicationPolicyRevision: policies.preview,
},
],
[
{
commit: POLICY_COMMIT,
tree: POLICY_TREE,
workflow: PRIME_STABLE_WORKFLOW,
publicationPolicyRevision: policies.promotion,
},
],
]);
const preview = syntheticPublication("preview", policies);
await expect(verifyPrimePublicationFixture(preview, validVerification)).resolves.toEqual(
preview.verified,
);
});

it.each([
{ preview: 3, build: 2, promotion: 2 },
{ preview: 2, build: 3, promotion: 2 },
{ preview: 2, build: 2, promotion: 3 },
])("rejects an unknown policy revision in any manifest position: %j", async (policies) => {
await expect(
verifyPrimePublicationFixture(syntheticPublication("stable", policies), validVerification),
).rejects.toThrow(/Unsupported Pylon publication policy revision/u);
});

it.each([
{ preview: 1, build: 2, promotion: 2 },
{ preview: 2, build: 1, promotion: 2 },
])("rejects a stable build policy different from its exact preview: %j", async (policies) => {
await expect(
verifyPrimePublicationFixture(syntheticPublication("stable", policies), validVerification),
).rejects.toThrow(/Stable manifest does not bind the exact verified preview/u);
});

it.each([1, 2])("checks actual fetched workflow bytes under policy %i", async (revision) => {
const fixture = syntheticPublication("preview", {
preview: revision,
build: revision,
promotion: revision,
});
const harness = makeNetworkHarness({
channel: "preview",
candidates: 1,
key: `policy-bytes-${revision}`,
fixture,
});
const { verifySourcePolicy: _injectedSourcePolicy, ...dependencies } =
harness.dependencyShape();
const workflowUrl = `https://raw.githubusercontent.com/${PRIME_DISTRIBUTION_REPOSITORY}/${SOURCE_COMMIT}/${PRIME_PREVIEW_WORKFLOW}`;
const fetchBytes = vi.fn(async (url: string, maximumBytes: number) => {
if (url === workflowUrl) return Buffer.from("altered publication workflow");
return dependencies.fetchBytes(url, maximumBytes);
});
const load = makeLatestPrimePublicationLoader({
...dependencies,
fetchJson: async (url, maximumBytes) =>
url.endsWith(`/git/commits/${SOURCE_COMMIT}`)
? { sha: SOURCE_COMMIT, tree: { sha: SOURCE_TREE } }
: dependencies.fetchJson(url, maximumBytes),
fetchBytes,
});
await expect(load("preview")).rejects.toThrow(/No exact signed preview publication verified/u);
expect(fetchBytes.mock.calls.some(([url]) => url === workflowUrl)).toBe(true);
});

it("fails closed for tarball, manifest, attestation, source, recipe, and stable history tampering", async () => {
const fixture = syntheticPublication();
await expect(
Expand Down
54 changes: 34 additions & 20 deletions apps/server/src/provider/prime/PrimeAgentDistributionVerifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,13 +377,30 @@ export const PRIME_PUBLICATION_SCHEMA_SOURCE = Object.freeze({
commit: "f4d9ef03b529faf2e07031c8b7cd703363316ae5",
tree: "b9a14b389aa64f54527008fb4d6119a7c57c2b58",
});
export const PRIME_PUBLICATION_POLICY = Object.freeze({
publicationPolicyRevision: 1,
previewWorkflowPath: PRIME_PREVIEW_WORKFLOW,
previewWorkflowSha256: "e790a5da7063bd40fbd886e84945c3200291194fdbd5b002079349e45356a41d",
stableWorkflowPath: PRIME_STABLE_WORKFLOW,
stableWorkflowSha256: "dfcecdf6b58f143f9b7a543eadd124c190350ae29ac9eadccb907f1398b0958a",
});
export const PRIME_PUBLICATION_POLICIES = Object.freeze([
Object.freeze({
publicationPolicyRevision: 1,
previewWorkflowPath: PRIME_PREVIEW_WORKFLOW,
previewWorkflowSha256: "e790a5da7063bd40fbd886e84945c3200291194fdbd5b002079349e45356a41d",
stableWorkflowPath: PRIME_STABLE_WORKFLOW,
stableWorkflowSha256: "dfcecdf6b58f143f9b7a543eadd124c190350ae29ac9eadccb907f1398b0958a",
}),
Object.freeze({
publicationPolicyRevision: 2,
previewWorkflowPath: PRIME_PREVIEW_WORKFLOW,
previewWorkflowSha256: "9f4e3f38fb0bdb9c11662310c5369fb792765a3090e0f74b0ec0b34127b43ed8",
stableWorkflowPath: PRIME_STABLE_WORKFLOW,
stableWorkflowSha256: "0f04d1f55f54312d933087d88de6883e8408bb0cd9f060d3b5851d710698b1af",
}),
]);

function publicationPolicyFor(revision: number) {
const policy = PRIME_PUBLICATION_POLICIES.find(
(candidate) => candidate.publicationPolicyRevision === revision,
);
if (!policy) throw new Error("Unsupported Pylon publication policy revision.");
return policy;
}

function compareText(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
Expand Down Expand Up @@ -499,8 +516,8 @@ function parsePreviewManifest(
MAX_MANIFEST_BYTES,
decodePreviewManifest,
);
publicationPolicyFor(manifest.publicationPolicyRevision);
if (
manifest.publicationPolicyRevision !== PRIME_PUBLICATION_POLICY.publicationPolicyRevision ||
manifest.build.tag !== release.build.id ||
manifest.build.id !== release.build.id ||
manifest.build.recipeRevision !== release.build.recipeRevision ||
Expand Down Expand Up @@ -529,6 +546,8 @@ function parseStableManifest(bytes: Buffer): StableManifest {
MAX_MANIFEST_BYTES,
decodeStableManifest,
);
publicationPolicyFor(manifest.build.publicationPolicyRevision);
publicationPolicyFor(manifest.promotion.publicationPolicyRevision);
const stableMatch = /^pylon-stable-([0-9]{6})-g([0-9a-f]{12})-r([1-9][0-9]*)$/.exec(manifest.tag);
if (
!stableMatch ||
Expand All @@ -542,11 +561,7 @@ function parseStableManifest(bytes: Buffer): StableManifest {
Number(/^pylon-stable-([0-9]{6})-/.exec(manifest.history.previous.tag)?.[1]) !==
manifest.sequence - 1) ||
manifest.build.previewTag !== manifest.build.id ||
manifest.build.recipeRevision !== PRIME_RELEASE_RECIPE.recipeRevision ||
manifest.build.publicationPolicyRevision !==
PRIME_PUBLICATION_POLICY.publicationPolicyRevision ||
manifest.promotion.publicationPolicyRevision !==
PRIME_PUBLICATION_POLICY.publicationPolicyRevision
manifest.build.recipeRevision !== PRIME_RELEASE_RECIPE.recipeRevision
) {
throw new Error("Stable manifest is not an exact closed Pylon build receipt.");
}
Expand Down Expand Up @@ -842,6 +857,7 @@ export async function verifyPrimePublicationFixture(
stable.build.previewTag !== preview.build.tag ||
stable.build.id !== preview.build.id ||
stable.build.recipeRevision !== preview.build.recipeRevision ||
stable.build.publicationPolicyRevision !== preview.publicationPolicyRevision ||
canonicalPrimeDistributionJson(stable.build.source) !==
canonicalPrimeDistributionJson(preview.build.source) ||
canonicalPrimeDistributionJson(stable.build.assets) !==
Expand Down Expand Up @@ -1695,9 +1711,7 @@ async function verifyRemoteSourcePolicy(
expected: PrimeSourcePolicyExpectation,
dependencies: PrimeDistributionNetworkDependencies,
): Promise<void> {
if (expected.publicationPolicyRevision !== PRIME_PUBLICATION_POLICY.publicationPolicyRevision) {
throw new Error("Unsupported Pylon publication policy revision.");
}
const policy = publicationPolicyFor(expected.publicationPolicyRevision);
const commit = decodeGitHubCommit(
await dependencies.fetchJson(
`https://api.github.com/repos/${PRIME_DISTRIBUTION_REPOSITORY}/git/commits/${expected.commit}`,
Expand All @@ -1713,8 +1727,8 @@ async function verifyRemoteSourcePolicy(
);
const expectedDigest =
expected.workflow === PRIME_PREVIEW_WORKFLOW
? PRIME_PUBLICATION_POLICY.previewWorkflowSha256
: PRIME_PUBLICATION_POLICY.stableWorkflowSha256;
? policy.previewWorkflowSha256
: policy.stableWorkflowSha256;
if (sha256(workflowBytes) !== expectedDigest) {
throw new Error("Signer workflow bytes do not match the frozen publication policy revision.");
}
Expand Down Expand Up @@ -2281,12 +2295,12 @@ export async function verifyPrimePublicationArtifactDirectory(input: {
verifyBundle: async (bundle, expected) =>
verifyPrimeSigstoreBundle(bundle, trustedRoot, expected),
verifySourcePolicy: async (expected) => {
const policy = publicationPolicyFor(expected.publicationPolicyRevision);
if (
expected.workflow !== PRIME_PREVIEW_WORKFLOW ||
expected.publicationPolicyRevision !== PRIME_PUBLICATION_POLICY.publicationPolicyRevision ||
expected.commit !== commit.sha ||
expected.tree !== commit.tree.sha ||
sha256(workflowBytes) !== PRIME_PUBLICATION_POLICY.previewWorkflowSha256
sha256(workflowBytes) !== policy.previewWorkflowSha256
) {
throw new Error(
"Prime graduation source head, tree, workflow, or policy revision is not exact.",
Expand Down
7 changes: 7 additions & 0 deletions docs/internals/prime-agent-distribution-verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ Unknown recipes, policy revisions, fields, assets, or tag shapes fail closed. Th
both publication workflow byte digests. A publication workflow change therefore needs a new reviewed
policy revision rather than a permissive parser change.

The immutable registry retains revision 1 and adds revision 2 from the reviewed publication changes
in [Prime Agent PR #56](https://github.com/pylon-code/prime-agent/pull/56). Each revision pins its own
exact preview and stable workflow bytes. A stable receipt's build policy must equal its verified
preview's policy; its promotion policy is checked independently against the promotion commit and tree.
This permits a revision-1 preview to be promoted under revision 2 without changing its original build
provenance. Network verification and real-artifact graduation resolve the same frozen policies.

The server uses `@sigstore/bundle`, `@sigstore/core`, `@sigstore/tuf`, and `@sigstore/verify` directly.
It requires a current Sigstore bundle with an inclusion proof, one verified Rekor timestamp, one
verified certificate-transparency timestamp, the GitHub OIDC issuer, and these exact certificate and
Expand Down
Loading