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
5 changes: 5 additions & 0 deletions .changeset/calm-seals-verify.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@emdash-cms/registry-verification": patch
---

Fixes delegated-release provenance verification so verified GitHub attestations include the repository, workflow, commit, and run identity needed to enforce an exact authorized workload.
12 changes: 8 additions & 4 deletions apps/release-service/src/publisher-do/publisher-do.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ import type {
EncryptionRecordReplacement,
} from "../operations/encryption-records.js";
import { MAX_ENCRYPTION_RECORD_PAGE } from "../operations/encryption-records.js";
import { digestWorkloadIdentity } from "../workload/policy.js";
import { parseStoredWorkloadIdentity } from "../workload/types.js";
import { parseStoredWorkloadIdentity } from "../workload/stored-identity.js";
import {
initializeIntentStateSchema,
IntentStateStore,
Expand Down Expand Up @@ -1108,12 +1107,17 @@ export class PublisherDurableObject extends DurableObject<Env> {
): Promise<AdvancePublicationOperationPhaseResult> {
this.#assertPublisherDid(input.publisherDid);
const intent = input.phase === "creating" ? this.#intents.get(input.intentId) : null;
const identity = intent ? parseStoredWorkloadIdentity(intent.workloadIdentityJson) : null;
const identity = intent
? await parseStoredWorkloadIdentity(
intent.workloadIdentityJson,
intent.workloadIdentityDigest,
)
: null;
const authorization =
intent && identity
? {
identity,
identityDigest: await digestWorkloadIdentity(identity),
identityDigest: intent.workloadIdentityDigest,
identityJson: intent.workloadIdentityJson,
}
: null;
Expand Down
16 changes: 16 additions & 0 deletions apps/release-service/src/publishing/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
StoredPublicationMaterialization,
} from "../publisher-do/publisher-do.js";
import {
evaluateWorkloadAttestation,
evaluateVerifiedRelease,
normalizeVerifierReport,
parseNormalizedVerifierReport,
Expand Down Expand Up @@ -754,6 +755,7 @@ export async function publishVerifiedIntent(
publisherDid,
originalIntent,
snapshot,
await publisher.getWorkloadPolicy(publisherDid, originalIntent.packageSlug),
verifier,
);
if (!evaluation.success) {
Expand Down Expand Up @@ -814,6 +816,13 @@ export async function publishVerifiedIntent(
reasonCode: finalVerification.reasonCode,
};
}
if (current?.state === "expired") {
return {
intentId: originalIntent.id,
state: "expired",
reasonCode: "INTENT_EXPIRED",
};
}
if (current?.state !== "ready") {
return {
intentId: originalIntent.id,
Expand Down Expand Up @@ -1087,6 +1096,7 @@ export async function publishVerifiedIntent(
publisherDid,
originalIntent,
snapshot,
await publisher.getWorkloadPolicy(publisherDid, originalIntent.packageSlug),
verifier,
);
if (
Expand Down Expand Up @@ -1145,6 +1155,12 @@ export async function publishVerifiedIntent(
originalIntent.requestDigest,
);
if (!persistedRecord) return failBeforeWrite("MATERIALIZATION_UNAVAILABLE");
const workload = await evaluateWorkloadAttestation(
originalIntent,
await publisher.getWorkloadPolicy(publisherDid, originalIntent.packageSlug),
verifier.value.provenance,
);
if (!workload.ok) return failBeforeWrite(workload.reasonCode);
await requireCurrentPublicationAudience(publisher, publisherDid, restored);
const creatingPhase = await publisher.advancePublicationOperationPhase({
...completionBase,
Expand Down
87 changes: 86 additions & 1 deletion apps/release-service/src/verification/evaluate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ import type {
} from "../../../release-verifier/src/verify.js";
import type { ApprovalEvidence } from "../approvals/digest.js";
import type { StoredIntent } from "../publisher-do/publisher-do.js";
import type { StoredWorkloadPolicy } from "../publisher-do/workload-policy.js";
import { evaluateWorkloadPolicy } from "../workload/policy.js";
import { parseStoredWorkloadIdentity } from "../workload/stored-identity.js";
import type { PublisherVerificationSnapshot } from "./pds.js";

const SLSA_PROVENANCE_V1 = "https://slsa.dev/provenance/v1";
Expand All @@ -31,7 +34,17 @@ export type VerificationEvaluationCode =
| "BASELINE_INVALID"
| "INTENT_INPUT_INVALID"
| "RECORD_INVALID"
| "VERIFIER_REJECTED";
| "VERIFIER_REJECTED"
| "WORKLOAD_IDENTITY_INVALID";

export interface VerifiedProvenanceIdentity {
sourceRepository: string;
builderId: string;
repositoryId: string;
workflowRef: string;
commitSha: string;
invocationId: string;
}

export type VerificationEvaluation =
| {
Expand Down Expand Up @@ -66,6 +79,10 @@ export type NormalizedVerifierReport =
predicateType: string;
sourceRepository: string;
builderId: string;
repositoryId: string;
workflowRef: string;
commitSha: string;
invocationId: string;
};
};
}
Expand Down Expand Up @@ -160,6 +177,10 @@ export function parseNormalizedVerifierReport(value: string): NormalizedVerifier
predicateType: stringField(provenance["predicateType"]),
sourceRepository: stringField(provenance["sourceRepository"]),
builderId: stringField(provenance["builderId"]),
repositoryId: stringField(provenance["repositoryId"]),
workflowRef: stringField(provenance["workflowRef"]),
commitSha: stringField(provenance["commitSha"]),
invocationId: stringField(provenance["invocationId"]),
};
if (
normalized.requestedUrl === null ||
Expand All @@ -176,6 +197,10 @@ export function parseNormalizedVerifierReport(value: string): NormalizedVerifier
normalized.predicateType === null ||
normalized.sourceRepository === null ||
normalized.builderId === null ||
normalized.repositoryId === null ||
normalized.workflowRef === null ||
normalized.commitSha === null ||
normalized.invocationId === null ||
!("declaredAccess" in manifest)
) {
return null;
Expand Down Expand Up @@ -206,6 +231,10 @@ export function parseNormalizedVerifierReport(value: string): NormalizedVerifier
predicateType: normalized.predicateType,
sourceRepository: normalized.sourceRepository,
builderId: normalized.builderId,
repositoryId: normalized.repositoryId,
workflowRef: normalized.workflowRef,
commitSha: normalized.commitSha,
invocationId: normalized.invocationId,
},
},
};
Expand Down Expand Up @@ -319,19 +348,75 @@ function reportBackedVerifier(
artifactDigest: new Uint8Array(input.artifactDigest),
sourceRepository: report.provenance.sourceRepository,
builderId: report.provenance.builderId,
repositoryId: report.provenance.repositoryId,
workflowRef: report.provenance.workflowRef,
commitSha: report.provenance.commitSha,
invocationId: report.provenance.invocationId,
},
};
},
};
}

export async function evaluateWorkloadAttestation(
intent: Pick<StoredIntent, "packageSlug" | "workloadIdentityDigest" | "workloadIdentityJson">,
policy: StoredWorkloadPolicy | null,
provenance: VerifiedProvenanceIdentity,
): Promise<{ ok: true } | { ok: false; reasonCode: string }> {
const identity = await parseStoredWorkloadIdentity(
intent.workloadIdentityJson,
intent.workloadIdentityDigest,
);
if (!identity) return { ok: false, reasonCode: "WORKLOAD_IDENTITY_INVALID" };
if (!policy || policy.packageSlug !== intent.packageSlug) {
return { ok: false, reasonCode: "WORKLOAD_POLICY_UNAVAILABLE" };
}
const policyDecision = evaluateWorkloadPolicy(identity, policy);
if (!policyDecision.ok) return { ok: false, reasonCode: policyDecision.code };
const workflowMarker = "/.github/workflows/";
const markerIndex = identity.workflow.ref.toLowerCase().indexOf(workflowMarker);
if (markerIndex < 1) {
return { ok: false, reasonCode: "ATTESTED_WORKFLOW_MISMATCH" };
}
const sourceRepository = `https://github.com/${identity.workflow.ref.slice(0, markerIndex)}`;
if (
provenance.repositoryId !== identity.repository.id ||
provenance.sourceRepository.toLowerCase() !== sourceRepository.toLowerCase()
) {
return { ok: false, reasonCode: "ATTESTED_REPOSITORY_MISMATCH" };
}
Comment on lines +375 to +387

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] sourceRepository is built from identity.repository.name, which github-oidc.ts lowercases when normalizing the OIDC token. The Sigstore-attested builderId and OID 21 invocationId, however, preserve the original casing from the GitHub workflow_ref claim. Repositories with uppercase letters therefore pass admission-time policy matching (which normalizes case) but fail here with ATTESTED_WORKFLOW_MISMATCH or ATTESTED_INVOCATION_MISMATCH because expectedBuilderId and invocationId use a lowercased prefix.

Derive the repo prefix from identity.workflow.ref — the parser already validates that the ref starts with the repository — and compare source repositories case-insensitively. Add a regression test that upper-cases the provenance builderId/invocationId (or the stored workflow ref).

Suggested change
if (!policyDecision.ok) return { ok: false, reasonCode: policyDecision.code };
const sourceRepository = `https://github.com/${identity.repository.name}`;
if (
provenance.repositoryId !== identity.repository.id ||
provenance.sourceRepository.toLowerCase() !== sourceRepository
) {
return { ok: false, reasonCode: "ATTESTED_REPOSITORY_MISMATCH" };
}
const workflowMarker = "/.github/workflows/";
const markerIndex = identity.workflow.ref.toLowerCase().indexOf(workflowMarker);
if (markerIndex < 1) {
return { ok: false, reasonCode: "ATTESTED_WORKFLOW_MISMATCH" };
}
const sourceRepository = `https://github.com/${identity.workflow.ref.slice(0, markerIndex)}`;
if (
provenance.repositoryId !== identity.repository.id ||
provenance.sourceRepository.toLowerCase() !== sourceRepository.toLowerCase()
) {
return { ok: false, reasonCode: "ATTESTED_REPOSITORY_MISMATCH" };
}
const expectedBuilderId = `${sourceRepository}${identity.workflow.ref.slice(markerIndex)}`;
if (provenance.builderId !== expectedBuilderId) {
return { ok: false, reasonCode: "ATTESTED_WORKFLOW_MISMATCH" };
}

const expectedBuilderId = `${sourceRepository}${identity.workflow.ref.slice(markerIndex)}`;
if (provenance.builderId !== expectedBuilderId) {
return { ok: false, reasonCode: "ATTESTED_WORKFLOW_MISMATCH" };
}
const workflowRef = identity.workflow.ref.slice(identity.workflow.ref.lastIndexOf("@") + 1);
if (provenance.workflowRef !== workflowRef) {
return { ok: false, reasonCode: "ATTESTED_REF_MISMATCH" };
}
if (provenance.commitSha !== identity.run.commitSha) {
return { ok: false, reasonCode: "ATTESTED_COMMIT_MISMATCH" };
}
const invocationId = `${sourceRepository}/actions/runs/${identity.run.id}/attempts/${identity.run.attempt}`;
if (provenance.invocationId !== invocationId) {
return { ok: false, reasonCode: "ATTESTED_INVOCATION_MISMATCH" };
}
return { ok: true };
}

export async function evaluateVerifiedRelease(
publisherDid: string,
intent: StoredIntent,
snapshot: PublisherVerificationSnapshot,
workloadPolicy: StoredWorkloadPolicy | null,
verifierReport: NormalizedVerifierReport,
): Promise<VerificationEvaluation> {
if (!verifierReport.success) return failed("VERIFIER_REJECTED", verifierReport.error.code);
const workload = await evaluateWorkloadAttestation(
intent,
workloadPolicy,
verifierReport.value.provenance,
);
if (!workload.ok) return failed("WORKLOAD_IDENTITY_INVALID", workload.reasonCode);
const payload = parseReleaseIntent(intent.releaseInputJson);
const verifierInput = prepareVerifierInput(intent, snapshot);
if (!payload || !verifierInput) return failed("INTENT_INPUT_INVALID");
Expand Down
5 changes: 5 additions & 0 deletions apps/release-service/src/workflows/release-intent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -548,10 +548,15 @@ export class ReleaseIntentWorkflow extends WorkflowEntrypoint<
) {
result = { success: false, code: "BASELINE_INVALID", reasonCode: "BASELINE_CHANGED" };
} else {
const workloadPolicy = await publisher.getWorkloadPolicy(
params.publisherDid,
intent.packageSlug,
);
const evaluated = await evaluateVerifiedRelease(
params.publisherDid,
intent,
snapshot,
workloadPolicy,
verifier,
);
result = evaluated.success
Expand Down
146 changes: 146 additions & 0 deletions apps/release-service/src/workload/stored-identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { digestWorkloadIdentity } from "./policy.js";
import type { VerifiedWorkloadIdentity } from "./types.js";

const DECIMAL_ID_PATTERN = /^[1-9][0-9]*$/;
const REPOSITORY_PATTERN = /^[a-z0-9_.-]+\/[a-z0-9_.-]+$/;
const LOGIN_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,38})$/;
const ACTOR_PATTERN = /^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})|[A-Za-z0-9-]{1,39}\[bot\])$/;
const SHA_PATTERN = /^[a-f0-9]{40}$/;
const REF_PATTERN = /^refs\/[A-Za-z0-9._/-]{1,507}$/;
const WORKFLOW_REF_PATTERN =
/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/\.github\/workflows\/[A-Za-z0-9_./-]+\.ya?ml@refs\/[A-Za-z0-9._/-]+$/;

function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

function boundedString(value: unknown, maximum: number, pattern?: RegExp): string | null {
return typeof value === "string" &&
value.length > 0 &&
value.length <= maximum &&
(!pattern || pattern.test(value))
? value
: null;
}

function nullableString(
value: unknown,
maximum: number,
pattern?: RegExp,
): string | null | undefined {
return value === null ? null : (boundedString(value, maximum, pattern) ?? undefined);
}

function safeInteger(value: unknown, minimum = 0): number | null {
return Number.isSafeInteger(value) && Number(value) >= minimum ? Number(value) : null;
}

function parseIdentity(value: unknown): VerifiedWorkloadIdentity | null {
if (
!isRecord(value) ||
!isRecord(value["repository"]) ||
!isRecord(value["workflow"]) ||
!isRecord(value["run"])
) {
return null;
}
const repository = value["repository"];
const workflow = value["workflow"];
const run = value["run"];
const subject = boundedString(value["subject"], 2048);
const tokenId = boundedString(value["tokenId"], 255);
const repositoryName = boundedString(repository["name"], 256, REPOSITORY_PATTERN);
const repositoryId = boundedString(repository["id"], 32, DECIMAL_ID_PATTERN);
const repositoryOwner = boundedString(repository["owner"], 64, LOGIN_PATTERN);
const repositoryOwnerId = boundedString(repository["ownerId"], 32, DECIMAL_ID_PATTERN);
const workflowRef = boundedString(workflow["ref"], 1024, WORKFLOW_REF_PATTERN);
const workflowSha = boundedString(workflow["sha"], 40, SHA_PATTERN);
const jobRef = nullableString(workflow["jobRef"], 1024, WORKFLOW_REF_PATTERN);
const jobSha = nullableString(workflow["jobSha"], 40, SHA_PATTERN);
const runId = boundedString(run["id"], 32, DECIMAL_ID_PATTERN);
const runAttempt = safeInteger(run["attempt"], 1);
const actor = boundedString(run["actor"], 64, ACTOR_PATTERN);
const actorId = boundedString(run["actorId"], 32, DECIMAL_ID_PATTERN);
const eventName = boundedString(run["eventName"], 128);
const ref = boundedString(run["ref"], 512, REF_PATTERN);
const commitSha = boundedString(run["commitSha"], 40, SHA_PATTERN);
const environment = nullableString(run["environment"], 255);
const issuedAt = safeInteger(value["issuedAt"]);
const expiresAt = safeInteger(value["expiresAt"]);
if (
value["issuer"] !== "github-actions" ||
!subject ||
!tokenId ||
!repositoryName ||
!repositoryId ||
!repositoryOwner ||
!repositoryOwnerId ||
(repository["visibility"] !== "public" &&
repository["visibility"] !== "private" &&
repository["visibility"] !== "internal") ||
!workflowRef ||
!workflowSha ||
jobRef === undefined ||
jobSha === undefined ||
(jobRef === null) !== (jobSha === null) ||
!runId ||
runAttempt === null ||
!actor ||
!actorId ||
!eventName ||
!ref ||
(run["refType"] !== "branch" && run["refType"] !== "tag") ||
!commitSha ||
environment === undefined ||
(run["runnerEnvironment"] !== "github-hosted" && run["runnerEnvironment"] !== "self-hosted") ||
issuedAt === null ||
expiresAt === null ||
issuedAt > expiresAt ||
repositoryOwner !== repositoryName.split("/", 1)[0] ||
!workflowRef.toLowerCase().startsWith(`${repositoryName}/.github/workflows/`)
) {
return null;
}
return {
issuer: "github-actions",
subject,
tokenId,
repository: {
name: repositoryName,
id: repositoryId,
owner: repositoryOwner,
ownerId: repositoryOwnerId,
visibility: repository["visibility"],
},
workflow: { ref: workflowRef, sha: workflowSha, jobRef, jobSha },
run: {
id: runId,
attempt: runAttempt,
actor,
actorId,
eventName,
ref,
refType: run["refType"],
commitSha,
environment,
runnerEnvironment: run["runnerEnvironment"],
},
issuedAt,
expiresAt,
};
}

export async function parseStoredWorkloadIdentity(
json: string,
expectedDigest: string,
): Promise<VerifiedWorkloadIdentity | null> {
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch {
return null;
}
const identity = parseIdentity(parsed);
if (!identity || JSON.stringify(identity) !== json) return null;
return (await digestWorkloadIdentity(identity)) === expectedDigest ? identity : null;
}
Loading
Loading