Skip to content

fix: proof-verify delegated release authority - #2849

Merged
ascorbic merged 4 commits into
mainfrom
fix/proof-verified-release-authority
Sep 2, 2026
Merged

fix: proof-verify delegated release authority#2849
ascorbic merged 4 commits into
mainfrom
fix/proof-verified-release-authority

Conversation

@ascorbic

@ascorbic ascorbic commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes the delegated release service so unsigned PDS response envelopes cannot choose approvers or suppress access-escalation approval.

  • Carries the immutable approver DID set from the awaiting_approval transition into every approval authorization check.
  • Reads the current package profile through the direct PDS repository-proof path and compares its verified CID with the approval evidence.
  • Selects release baselines from a signature-verified, complete repository CAR instead of repo.listRecords JSON.
  • Adds attack-focused coverage for CID/value substitution, an attacker-DID passkey, an injected higher-semver baseline, and an omitted genuine baseline.

Addresses the security audit finding “Unsigned PDS envelopes drive approval and access-baseline decisions.”

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes — affected package typechecks pass; the repository-wide command was not run.
  • pnpm lint passes — changed files have zero type-aware diagnostics; the repository-wide pre-edit baseline was not clean.
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run — the repository formatter was run on every changed file, without formatting unrelated untracked files in the worktree.
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main.
  • I have added and reviewed the user-facing changeset (if this PR changes a published package)
  • New features link to an approved Discussion: N/A — this is a security bug fix.

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: OpenAI GPT-5 (Codex)

Screenshots / test output

No visual changes.

  • Release-service main suite: 453/453 passed with --maxWorkers=1.
  • Release-service encryption v2: 1/1 passed.
  • Release-service UI: 14/14 passed.
  • Registry client: 150/150 passed.
  • AT Protocol test utilities: 17/17 passed.
  • Affected package typechecks passed.
  • Changed files: zero type-aware lint diagnostics.

@github-actions github-actions Bot added the review/needs-review No maintainer or bot review yet label Sep 2, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 2, 2026

Copy link
Copy Markdown

🚀 Deploying Preview to Cloudflare 🚀

Preview URL: https://fix-proof-verified-release-authority.try.emdashcms.com, https://fix-proof-verified-release-authority-emdash-playground.emdash-cms.workers.dev (commit 3f7ef8d)

This URL reflects your latest Preview deployment

Preview Deployments by commit

Status Deployment URL Commit Updated (UTC) See this deployment's details
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://daf54aa4.try.emdashcms.com, https://daf54aa4-emdash-playground.emdash-cms.workers.dev 3f7ef8d 2026-09-02T11:45:41.817Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://e84b55df.try.emdashcms.com, https://e84b55df-emdash-playground.emdash-cms.workers.dev 8df40f8 2026-09-02T11:28:41.747Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://89bf3962.try.emdashcms.com, https://89bf3962-emdash-playground.emdash-cms.workers.dev 7284b99 2026-09-02T09:57:41.070Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://40761ee6.try.emdashcms.com, https://40761ee6-emdash-playground.emdash-cms.workers.dev 02824fa 2026-09-02T09:22:50.183Z Visit the dashboard ↗

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 630 lines across 12 files. Large PRs are harder to review and more likely to be closed without review.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@pkg-pr-new

pkg-pr-new Bot commented Sep 2, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@2849

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@2849

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@2849

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@2849

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@2849

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@2849

emdash

npm i https://pkg.pr.new/emdash@2849

create-emdash

npm i https://pkg.pr.new/create-emdash@2849

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@2849

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@2849

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@2849

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@2849

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@2849

@emdash-cms/registry-moderation

npm i https://pkg.pr.new/@emdash-cms/registry-moderation@2849

@emdash-cms/registry-verification

npm i https://pkg.pr.new/@emdash-cms/registry-verification@2849

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@2849

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@2849

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@2849

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@2849

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@2849

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@2849

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@2849

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@2849

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@2849

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@2849

commit: 3f7ef8d

@emdashbot emdashbot Bot left a comment

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.

The approach is sound and directly addresses the audit finding. Moving approval-authority verification and baseline selection onto proof-verified repository CARs is the right design, and carrying the immutable approver set from the awaiting_approval transition closes the unsigned-envelope attack path. The code fits the release-service architecture and the test additions cover the advertised attack scenarios (CID substitution, attacker-DID passkey, injected baseline, omitted baseline).

I reviewed the changed source files, call sites in the release-intent/publishing workflows, and the new registry-client tests, and checked AGENTS.md conventions (changesets, comments, localization-invariant backend code, no logged-out hot-path changes). I did not run the test suite or type checker.

Headline conclusion: the PR is technically correct, but I have three suggestions before merge:

  1. Full-repo export now shares a 512 KiB response cap with single JSON page fetches. readPublisherVerificationSnapshot now downloads the entire publisher repo via com.atproto.sync.getRepo, but the existing MAX_PDS_RESPONSE_BYTES constant is still 512 * 1024. A publisher repo larger than that will fail verification with PUBLISHER_PDS_INVALID. The previous paginated repo.listRecords approach could handle much larger release histories. Either raise the cap for repo-export calls or document the hard limit.

  2. guardedFetch masks upstream 404s from DirectPdsClient. It rewrites upstream 404 responses to 200 so that repo.getRecord error bodies survive fetchVerifiedResource. That's correct for the JSON endpoint but wrong for sync.getRepo/sync.getRecord: a missing repo or record now surfaces as PDS_RESPONSE_TYPE_INVALID / PUBLISHER_PDS_INVALID instead of PUBLISHER_IDENTITY_INVALID or RECORD_NOT_FOUND. The new getPackageRepository path is affected.

  3. The changeset describes the new API but could be more useful. It names DirectPdsClient.getPackageRepository() accurately, but a one-sentence entry doesn't explain why a consumer should care (proof-verified reads replace trust in unsigned PDS envelopes) or show basic usage. Expanding it slightly would make the CHANGELOG more useful.


Findings

  • [suggestion] apps/release-service/src/verification/pds.ts:17

    The new getPackageRepository() path fetches the entire publisher repository export with com.atproto.sync.getRepo, but the response limit is still 512 * 1024, the same value previously used for a single JSON page. A publisher repo larger than that will fail baseline selection with PUBLISHER_PDS_INVALID and block legitimate releases. Give repo-export calls a larger, separate budget (or make it configurable) instead of reusing the page-sized cap.

    const MAX_PDS_RESPONSE_BYTES = 512 * 1024;
    const MAX_REPO_EXPORT_RESPONSE_BYTES = 5 * 1024 * 1024;
    

    Then use MAX_REPO_EXPORT_RESPONSE_BYTES at the getPackageRepository() client construction so single-record calls keep the tighter limit.

  • [suggestion] apps/release-service/src/verification/pds.ts:176

    guardedFetch rewrites every upstream 404 to a 200 so repo.getRecord error bodies can be read. That status rewrite is now inherited by the DirectPdsClient calls to sync.getRepo and sync.getRecord. A missing repo or record therefore no longer reaches the DirectPdsClient 404 branch; instead it becomes PDS_RESPONSE_TYPE_INVALID and in this file is mapped to the generic PUBLISHER_PDS_INVALID code instead of the more accurate PUBLISHER_IDENTITY_INVALID / RECORD_NOT_FOUND.

    Consider passing a separate fetch wrapper to the sync.* endpoints that preserves 404 status, or have DirectPdsClient read the true upstream status from the x-emdash-upstream-status header your wrapper already adds.

  • [suggestion] .changeset/clean-otters-prove.md:5

    The entry names the new API accurately, but it doesn't tell registry-client consumers why they should care or how to use it. Per .changeset/README.md, a minor feature changeset should give readers enough context to decide whether the release affects them.

    Adds `DirectPdsClient.getPackageRepository()` for reading a package profile and every package release from one proof-verified AT Protocol repository export.
    
    Use this instead of combining unsigned `com.atproto.repo.getRecord`/`listRecords` responses when you need a tamper-evident view of a publisher's package data:
    
    ```ts
    const { profile, releases } = await directPdsClient.getPackageRepository("gallery");
    

    The profile and release records are verified against the repository commit signature, so unsigned PDS envelopes cannot substitute values or omit records.

@ascorbic
ascorbic force-pushed the fix/proof-verified-release-authority branch from 02824fa to 7284b99 Compare September 2, 2026 09:52
@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Sep 2, 2026
@changeset-bot

changeset-bot Bot commented Sep 2, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3f7ef8d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 20 packages
Name Type
@emdash-cms/registry-client Minor
@emdash-cms/admin Patch
emdash Patch
@emdash-cms/plugin-cli Patch
@emdash-cms/release-action Patch
@emdash-cms/release-service Patch
@emdash-cms/cloudflare Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
create-emdash Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/needs-review No maintainer or bot review yet labels Sep 2, 2026

@emdashbot emdashbot Bot left a comment

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.

All three prior suggestions have been addressed, and a fresh pass over the changed source and tests found no new blocking issues.

  • Repo-export byte budget: apps/release-service/src/verification/pds.ts now uses DEFAULT_DIRECT_PDS_MAX_RESPONSE_BYTES (5 MiB) for getPackageRepository() instead of the 512 KiB single-record cap, and the test suite verifies it accepts a 600 KiB export.
  • 404 status preservation: guardedFetch() now records the upstream status in x-emdash-upstream-status and reconstructs the response with the real status, so sync.getRepo 404s correctly surface as REPOSITORY_NOT_FOUND/PUBLISHER_IDENTITY_INVALID.
  • Changeset: .changeset/clean-otters-prove.md now explains the new getPackageRepository() API, when to use it, and how the verification works.

The approach remains sound: approval authority checks the immutable approver set from the awaiting_approval transition before checking the proof-verified current profile CID, and baseline/release-absence decisions use the signed repository CAR. Attack-focused tests for CID substitution, attacker-DID passkeys, injected higher-semver baselines, and omitted genuine baselines are present and look meaningful.

I did not run the test suite, linter, or type checker; conclusions are static.

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Sep 2, 2026
@github-actions github-actions Bot added review/approved Approved; no new commits since and removed review/needs-rereview Author pushed changes since the last review labels Sep 2, 2026
@ascorbic
ascorbic force-pushed the fix/proof-verified-release-authority branch from 7284b99 to 8df40f8 Compare September 2, 2026 11:25
@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Sep 2, 2026
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/approved Approved; no new commits since labels Sep 2, 2026

@emdashbot emdashbot Bot left a comment

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.

This is a sound security fix that addresses the audit finding by moving approval authority and baseline/absence decisions from unsigned PDS envelopes to proof-verified repository state. Carrying the immutable approver set out of the awaiting_approval transition closes the approver-substitution path, and using a signed repository CAR (sync.getRepo) for profile/release reads closes the CID/value baseline path. The three items from the previous review are still in place: getPackageRepository() uses the 5 MiB default byte budget, guardedFetch() preserves upstream 404 status through x-emdash-upstream-status, and the changeset explains the new API.

I re-read the changed source and tests and did not find any new blocking security or regression issues. However I do have two polish findings:

  1. createGuardedIdentityFetch() in authority.ts forwards requests to fetchVerifiedResource() but drops the init.headers that DirectPdsClient.getPackageProfile() sets (Accept: application/vnd.ipld.car). A spec-compliant PDS may reject a sync.getRecord request without that header, which would surface as PROFILE_FETCH_FAILED. The sibling guardedFetch() in verification/pds.ts already merges the original headers; authority.ts should do the same.

  2. The MockPds docstring says sync.getRepo returns JSON, but the handler returns a CAR (application/vnd.ipld.car). It should be corrected so future readers don't expect JSON there.

Neither affects the security guarantee, and I remain comfortable with the overall approach.


Findings

  • [suggestion] apps/release-service/src/approvals/authority.ts:201-209

    createGuardedIdentityFetch() validates the method, then calls fetchVerifiedResource() with a callback that forwards only url and requestInit, dropping init.headers. The caller (DirectPdsClient.getPackageProfile()) sends Accept: application/vnd.ipld.car, which is therefore lost before the upstream PDS sees it. A strict PDS may return 406/400 for a sync.getRecord request without the expected Accept header, causing an avoidable PROFILE_FETCH_FAILED.

    Sibling guardedFetch() in src/verification/pds.ts avoids this by merging the original headers into the callback fetch. Apply the same pattern here:

    function createGuardedIdentityFetch(fetchImplementation: typeof fetch): typeof fetch {
    	return async (input, init) => {
    		const requestedUrl = new URL(input instanceof Request ? input.url : input.toString());
    		const method = init?.method ?? (input instanceof Request ? input.method : "GET");
    		if (method !== "GET") {
    			throw new ApprovalAuthorityError("PROFILE_FETCH_FAILED");
    		}
    		const resource = await fetchVerifiedResource(requestedUrl, {
    			fetch: (url, requestInit) =>
    				fetchImplementation(url, {
    					...requestInit,
    					headers: init?.headers ?? (input instanceof Request ? input.headers : undefined),
    				}),
    			resolveHostname: (hostname) => resolvePublicHostname(hostname, fetchImplementation),
    			headerTimeoutMs: 10_000,
    			totalTimeoutMs: 30_000,
    			maxBytes: MAX_PROFILE_RESPONSE_BYTES,
    			maxRedirects: 1,
    		});
    
  • [suggestion] packages/atproto-test-utils/src/mock-pds.ts:13-15

    The docstring says sync.getRepo returns JSON, but syncGetRepo() actually returns CAR bytes with Content-Type: application/vnd.ipld.car. This is a false claim in a code-level comment that can mislead maintainers about the handler contract.

     * Response shapes mirror the cirrus PDS reference implementation: CAR bytes
     * for `sync.getRecord` with `Content-Type: application/vnd.ipld.car`, CAR bytes
     * for `sync.getRepo`, JSON `{ records, cursor? }` for `listRecords`, and JSON
     * `{ uri, cid, value }` for `repo.getRecord`.
    

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Sep 2, 2026
@github-actions github-actions Bot added review/approved Approved; no new commits since and removed review/needs-rereview Author pushed changes since the last review labels Sep 2, 2026
@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Sep 2, 2026
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/approved Approved; no new commits since labels Sep 2, 2026

@emdashbot emdashbot Bot left a comment

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.

This remains the right fix for the audit finding. The PR moves approval-authority and access-baseline decisions from unsigned PDS envelopes to proof-verified repository state: verifyCurrentApprover now gates every approval on the immutable approver set captured at the awaiting_approval transition, and readPublisherVerificationSnapshot selects release baselines from a signed sync.getRepo CAR through DirectPdsClient.getPackageRepository(). That closes the unsigned-envelope approver-substitution, approver-suppression, and baseline-manipulation paths the audit identified.

Since the previous review:

  • createGuardedIdentityFetch() in authority.ts now forwards the caller's init.headers (e.g. Accept: application/vnd.ipld.car) into the guarded fetch, matching the sibling guardedFetch() in verification/pds.ts. ✅
  • MockPds’s docstring now correctly describes sync.getRepo as returning CAR bytes, not JSON. ✅

I re-read the updated source and tests and did not find any new blocking security, correctness, or convention issues:

  • DirectPdsClient.getPackageRepository() verifies the profile record against the publisher signing key before iterating the export, and release records are filtered to the package collection/rkey prefix and re-parsed against the package-release lexicon.
  • loadCurrentApprovalPolicy() checks the proof-verified profile CID against the approval evidence and validates/sorts the current approver policy.
  • Attack-focused tests cover CID/value substitution, an attacker-DID passkey, an injected higher-semver baseline, and an omitted genuine baseline.
  • The changeset for @emdash-cms/registry-client explains the new public surface, default size limit, and REPOSITORY_NOT_FOUND behavior.

No new needs_fixing or suggestion findings.

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Sep 2, 2026
@github-actions github-actions Bot added review/approved Approved; no new commits since and removed review/needs-rereview Author pushed changes since the last review labels Sep 2, 2026
@ascorbic
ascorbic enabled auto-merge (squash) September 2, 2026 12:44
@ascorbic
ascorbic disabled auto-merge September 2, 2026 12:53
@ascorbic
ascorbic merged commit 52fffdc into main Sep 2, 2026
51 checks passed
@ascorbic
ascorbic deleted the fix/proof-verified-release-authority branch September 2, 2026 12:54
@emdashbot emdashbot Bot mentioned this pull request Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review/approved Approved; no new commits since size/XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant