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
9 changes: 9 additions & 0 deletions .changeset/swift-verifiers-match.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@emdash-cms/registry-verification": minor
---

Adds optional artifact digest candidates to `GitHubProvenanceVerifier`, allowing callers that compute several supported digest algorithms in one isolated artifact fetch to verify the digest selected by a signed SLSA provenance subject.

Existing callers can continue passing only `artifactDigest`. Successful results return the candidate that matched the signed subject.

Fixes `@emdash-cms/registry-verification` when it is rebundled into an Astro Cloudflare application, preventing requests from failing during Worker startup.
28 changes: 28 additions & 0 deletions .changeset/verify-registry-bundles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
"emdash": minor
"@emdash-cms/admin": minor
"@emdash-cms/registry-client": minor
"@emdash-cms/registry-verification": minor
---

Adds `DirectPdsClient` for reading package profiles and releases with AT Protocol repository proofs, and updates experimental decentralized registry installs and updates to verify current signed records directly from the publisher's PDS.

#### Aggregator record integrity

Install and update reject aggregator-supplied profile or release metadata whose URI or CID does not match the publisher's signed records. The server returns `AGGREGATOR_RECORD_MISMATCH` before fetching the artifact or requesting consent.

#### Publisher identity display

The admin treats handle resolution as an advisory identity signal. It keeps the install button disabled while attempting to resolve the package DID back to a handle, then blocks installation when `resolveDidToHandle()` conclusively returns `"invalid"`. An indeterminate result caused by a network failure, unsupported DID method, or missing handle displays the publisher DID and does not block installation.

Install and update trust the publisher DID and the signed repository proofs for the profile and release records. A handle is display metadata and is not an authorization or record-integrity input.

#### Provenance and release policy

The installer applies the signed profile's release policy, independently fetches and verifies supplied Sigstore/SLSA provenance, and binds moderation labels to the exact profile or release CID. Missing required provenance and any supplied provenance that is unavailable, malformed, mismatched, or unsupported block installation and updates. Artifact checksums, archive paths, bundle limits, manifest identity, and version use the same verification rules as the registry release tooling.

The verification package also exports `inspectPackageReleaseRecords` for validating signed records and policy before artifact and provenance evidence is available.

Registry install and update consent now show the exact verified profile and release CIDs, signed publisher policy, and provenance status. Install consent uses permissions and MCP tools read from the verified bundle rather than the aggregator's record copy.

Install, update, and delegated-release verification require lowercase base32 multibase `sha2-256` multihashes for package artifacts and provenance documents. The plugin CLI already produces this format. The authenticated image-artifact proxy still accepts legacy bare hexadecimal SHA-256 checksums for display-only images.
6 changes: 5 additions & 1 deletion apps/release-service/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,17 @@
"@atcute/lexicons": "catalog:",
"@atcute/oauth-node-client": "catalog:",
"@emdash-cms/auth": "workspace:*",
"@emdash-cms/plugin-types": "workspace:*",
"@emdash-cms/registry-client": "workspace:*",
"@emdash-cms/registry-lexicons": "workspace:*",
"@emdash-cms/registry-verification": "workspace:*",
"jose": "^6.1.3"
"jose": "^6.1.3",
"semver": "catalog:"
},
"devDependencies": {
"@cloudflare/vite-plugin": "catalog:",
"@cloudflare/vitest-pool-workers": "catalog:",
"@types/semver": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
Expand Down
28 changes: 28 additions & 0 deletions apps/release-service/src/approvals/decision-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,28 @@ function matchApprovalPath(
return value && ULID_PATTERN.test(value) ? { intentId: value } : null;
}

async function notifyApprovalWorkflow(
workflowIntentId: string,
decision: "approve" | "reject",
approvalDigest: string,
): Promise<void> {
try {
const instance = await env.RELEASE_INTENT_WORKFLOW.get(workflowIntentId);
await instance.sendEvent({
type: "approval-decision",
payload: { decision, approvalDigest },
});
} catch (error) {
console.error(
JSON.stringify({
event: "approval_workflow_notification_failed",
intentId: workflowIntentId,
error: error instanceof Error ? error.message : String(error),
}),
);
}
}

export function matchApprovalResourcePath(
pathname: string,
): Readonly<Record<string, string>> | null {
Expand Down Expand Up @@ -327,6 +349,9 @@ export async function handleCompleteApprovalDecision(
if (!result.replayed || loaded.appliedApprovalDigest !== result.receipt.approvalDigest) {
throw new ApprovalAuthorityError("INTENT_NOT_APPROVABLE");
}
if (loaded.intent.workflowId === intent) {
await notifyApprovalWorkflow(intent, decision, result.receipt.approvalDigest);
}
return apiSuccess({ receipt: result.receipt, intent: loaded.intent }, requestId);
}
await verifyCurrentApprover(loaded.evidence, session.approverDid);
Expand All @@ -352,6 +377,9 @@ export async function handleCompleteApprovalDecision(
requestId,
);
}
if (loaded.intent.workflowId === intent) {
await notifyApprovalWorkflow(intent, decision, result.receipt.approvalDigest);
}
return apiSuccess({ receipt: result.receipt, intent: transition.intent }, requestId);
} catch (error) {
return apiFailure(mapApprovalError(error), requestId);
Expand Down
1 change: 1 addition & 0 deletions apps/release-service/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ROUTES, type RouteDefinition } from "./routes.js";

export { PublisherDurableObject } from "./publisher-do/publisher-do.js";
export { ApproverDurableObject } from "./approver-do/approver-do.js";
export { ReleaseIntentWorkflow } from "./workflows/release-intent.js";

export async function handleRequest(
request: Request,
Expand Down
11 changes: 8 additions & 3 deletions apps/release-service/src/publisher-do/intent-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export interface StoredIntent {
state: IntentState;
stateGeneration: number;
workloadPolicyVersion: number;
workloadIdentityDigest: string;
requestDigest: string;
workloadIdentityJson: string;
releaseInputJson: string;
Expand Down Expand Up @@ -136,6 +137,7 @@ interface IntentRow {
state: IntentState;
state_generation: number;
workload_policy_version: number;
workload_identity_digest: string;
request_digest: string;
workload_identity_json: string;
release_input_json: string;
Expand Down Expand Up @@ -207,6 +209,7 @@ function rowToIntent(row: IntentRow): StoredIntent {
state: row.state,
stateGeneration: row.state_generation,
workloadPolicyVersion: row.workload_policy_version,
workloadIdentityDigest: row.workload_identity_digest,
requestDigest: row.request_digest,
workloadIdentityJson: row.workload_identity_json,
releaseInputJson: row.release_input_json,
Expand All @@ -227,6 +230,7 @@ export function initializeIntentStateSchema(storage: DurableObjectStorage): void
state TEXT NOT NULL,
state_generation INTEGER NOT NULL CHECK (state_generation >= 1),
workload_policy_version INTEGER NOT NULL CHECK (workload_policy_version >= 1),
workload_identity_digest TEXT NOT NULL,
request_digest TEXT NOT NULL,
workload_identity_json TEXT NOT NULL,
release_input_json TEXT NOT NULL,
Expand Down Expand Up @@ -380,14 +384,15 @@ export class IntentStateStore {
this.#storage.sql.exec(
`INSERT INTO intents (
id, package_slug, version, state, state_generation,
workload_policy_version, request_digest, workload_identity_json,
workload_policy_version, workload_identity_digest, request_digest, workload_identity_json,
release_input_json, state_data_json, workflow_id,
expires_at, created_at, updated_at
) VALUES (?, ?, ?, 'received', 1, ?, ?, ?, ?, '{}', NULL, ?, ?, ?)`,
) VALUES (?, ?, ?, 'received', 1, ?, ?, ?, ?, ?, '{}', NULL, ?, ?, ?)`,
input.intentId,
input.packageSlug,
input.version,
input.workloadPolicyVersion,
input.workloadIdentityDigest,
input.requestDigest,
input.workloadIdentityJson,
input.releaseInputJson,
Expand Down Expand Up @@ -562,7 +567,7 @@ export class IntentStateStore {
const row = this.#storage.sql
.exec<IntentRow>(
`SELECT id, package_slug, version, state, state_generation,
workload_policy_version, request_digest, workload_identity_json,
workload_policy_version, workload_identity_digest, request_digest, workload_identity_json,
release_input_json, state_data_json, workflow_id,
expires_at, created_at, updated_at
FROM intents WHERE id = ?`,
Expand Down
36 changes: 36 additions & 0 deletions apps/release-service/src/publisher-do/publisher-do.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ import {
type CompletePublicationOperationInput,
type CompletePublicationOperationResult,
} from "./publication-operation.js";
import {
initializeVerificationStepSchema,
VerificationStepStore,
type PutVerificationStepInput,
type PutVerificationStepResult,
type StoredVerificationStep,
type VerificationStepName,
} from "./verification-step.js";
import {
initializeWorkloadPolicySchema,
WorkloadPolicyStore,
Expand Down Expand Up @@ -70,6 +78,12 @@ export type {
PublicationOperationPhase,
PublicationOutcome,
} from "./publication-operation.js";
export type {
PutVerificationStepInput,
PutVerificationStepResult,
StoredVerificationStep,
VerificationStepName,
} from "./verification-step.js";

const DID_PATTERN = /^did:[a-z][a-z0-9]*:[A-Za-z0-9._:%-]+$/;
const HASH_PATTERN = /^[A-Za-z0-9_-]{32,128}$/;
Expand Down Expand Up @@ -330,6 +344,7 @@ export class PublisherDurableObject extends DurableObject<Env> {
readonly #intents: IntentStateStore;
readonly #publicationMaterializations: PublicationMaterializationStore;
readonly #publicationOperations: PublicationOperationStore;
readonly #verificationSteps: VerificationStepStore;

constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
Expand All @@ -338,6 +353,7 @@ export class PublisherDurableObject extends DurableObject<Env> {
this.#intents = new IntentStateStore(ctx.storage);
this.#publicationMaterializations = new PublicationMaterializationStore(ctx.storage);
this.#publicationOperations = new PublicationOperationStore(ctx.storage);
this.#verificationSteps = new VerificationStepStore(ctx.storage);
void ctx.blockConcurrencyWhile(() => {
this.#initializeSchema();
return Promise.resolve();
Expand Down Expand Up @@ -418,6 +434,7 @@ export class PublisherDurableObject extends DurableObject<Env> {
initializeIntentStateSchema(this.ctx.storage);
initializePublicationMaterializationSchema(this.ctx.storage);
initializePublicationOperationSchema(this.ctx.storage);
initializeVerificationStepSchema(this.ctx.storage);
}

#assertPublisherObjectName(publisherDid: string): void {
Expand Down Expand Up @@ -513,6 +530,25 @@ export class PublisherDurableObject extends DurableObject<Env> {
return this.#intents.listTransitions(intentId);
}

putVerificationStep(input: PutVerificationStepInput): PutVerificationStepResult {
this.#assertPublisherDid(input.publisherDid);
return this.#verificationSteps.put(input);
}

getVerificationStep(
publisherDid: string,
intentId: string,
name: VerificationStepName,
): StoredVerificationStep | null {
this.#assertPublisherDid(publisherDid);
return this.#verificationSteps.get(intentId, name);
}

listVerificationSteps(publisherDid: string, intentId: string): readonly StoredVerificationStep[] {
this.#assertPublisherDid(publisherDid);
return this.#verificationSteps.list(intentId);
}

async beginPublicationOperation(
publisherDid: string,
intentId: string,
Expand Down
Loading
Loading