diff --git a/.changeset/clean-otters-prove.md b/.changeset/clean-otters-prove.md new file mode 100644 index 0000000000..6c9bdaf758 --- /dev/null +++ b/.changeset/clean-otters-prove.md @@ -0,0 +1,13 @@ +--- +"@emdash-cms/registry-client": minor +--- + +Adds `DirectPdsClient.getPackageRepository()` for reading a package profile and every package release from one proof-verified AT Protocol repository export. + +Use the method when authorization or version selection requires a complete signed package snapshot: + +```ts +const { profile, releases } = await directPdsClient.getPackageRepository("gallery"); +``` + +The client verifies the repository commit signature, record blocks, and complete Merkle search tree before returning records. Unsigned `repo.getRecord` and `repo.listRecords` envelopes cannot substitute or omit package data. Repository exports use the client's `maxResponseBytes` limit, which defaults to 5 MiB, and a missing export reports `REPOSITORY_NOT_FOUND`. diff --git a/apps/release-service/src/approvals/authority.ts b/apps/release-service/src/approvals/authority.ts index d4a7fb3469..497e99bbd4 100644 --- a/apps/release-service/src/approvals/authority.ts +++ b/apps/release-service/src/approvals/authority.ts @@ -1,10 +1,13 @@ -import type { ActorResolver } from "@atcute/identity-resolver"; import { safeParse } from "@atcute/lexicons"; import { isDid } from "@atcute/lexicons/syntax"; -import { NSID, PackageProfile, PackageProfileExtension } from "@emdash-cms/registry-lexicons"; +import { + DirectPdsClient, + DirectPdsReadError, + type DirectPdsDidDocumentResolver, +} from "@emdash-cms/registry-client/direct-pds"; +import { NSID, PackageProfileExtension } from "@emdash-cms/registry-lexicons"; import { fetchVerifiedResource } from "@emdash-cms/registry-verification/fetch"; -import { createWorkerActorResolver } from "../oauth/custody.js"; import type { IntentTransition, PublisherDurableObject, @@ -39,10 +42,11 @@ export interface LoadedApprovalIntent { appliedDecision: "approve" | "reject" | null; appliedApproverDid: string | null; appliedApprovalDigest: string | null; + approverDids: readonly string[]; } export interface VerifyCurrentApproverOptions { - actorResolver?: ActorResolver; + didDocumentResolver?: DirectPdsDidDocumentResolver; fetch?: typeof globalThis.fetch; } @@ -116,6 +120,7 @@ export async function loadApprovalIntent( appliedDecision, appliedApproverDid: appliedDecision ? (decisionTransition?.actorIdentity ?? null) : null, appliedApprovalDigest: appliedDecision ? (decisionTransition?.transitionDigest ?? null) : null, + approverDids: state.approverDids, }; } @@ -193,30 +198,6 @@ async function resolvePublicHostname( return [...ipv4, ...ipv6]; } -function profileRecordUrl(pds: string, publisherDid: string, packageSlug: string): URL { - let url: URL; - try { - url = new URL(pds); - } catch { - throw new ApprovalAuthorityError("PROFILE_FETCH_FAILED"); - } - if ( - url.protocol !== "https:" || - url.username !== "" || - url.password !== "" || - url.pathname !== "/" || - url.search !== "" || - url.hash !== "" - ) { - throw new ApprovalAuthorityError("PROFILE_FETCH_FAILED"); - } - url.pathname = "/xrpc/com.atproto.repo.getRecord"; - url.searchParams.set("repo", publisherDid); - url.searchParams.set("collection", NSID.packageProfile); - url.searchParams.set("rkey", packageSlug); - return url; -} - function createGuardedIdentityFetch(fetchImplementation: typeof fetch): typeof fetch { return async (input, init) => { const requestedUrl = new URL(input instanceof Request ? input.url : input.toString()); @@ -224,8 +205,13 @@ function createGuardedIdentityFetch(fetchImplementation: typeof fetch): typeof f if (method !== "GET") { throw new ApprovalAuthorityError("PROFILE_FETCH_FAILED"); } + const headers = init?.headers ?? (input instanceof Request ? input.headers : undefined); const resource = await fetchVerifiedResource(requestedUrl, { - fetch: (url, requestInit) => fetchImplementation(url, requestInit), + fetch: (url, requestInit) => + fetchImplementation(url, { + ...requestInit, + ...(headers === undefined ? {} : { headers }), + }), resolveHostname: (hostname) => resolvePublicHostname(hostname, fetchImplementation), headerTimeoutMs: 10_000, totalTimeoutMs: 30_000, @@ -244,12 +230,16 @@ function createGuardedIdentityFetch(fetchImplementation: typeof fetch): typeof f export async function verifyCurrentApprover( evidence: ApprovalEvidence, + immutableApproverDids: readonly string[], approverDid: string, options: VerifyCurrentApproverOptions = {}, ): Promise { if (!isDid(evidence.publisherDid) || !isDid(approverDid)) { throw new ApprovalAuthorityError("APPROVAL_EVIDENCE_INVALID"); } + if (!immutableApproverDids.includes(approverDid)) { + throw new ApprovalAuthorityError("APPROVER_NOT_AUTHORIZED"); + } const policy = await loadCurrentApprovalPolicy( evidence.publisherDid, evidence.packageSlug, @@ -272,52 +262,28 @@ export async function loadCurrentApprovalPolicy( throw new ApprovalAuthorityError("PROFILE_FETCH_FAILED"); } const fetchImplementation = options.fetch ?? globalThis.fetch; - let actor; + let record; try { - actor = await ( - options.actorResolver ?? - createWorkerActorResolver(createGuardedIdentityFetch(fetchImplementation)) - ).resolve(publisherDid, { signal: AbortSignal.timeout(30_000), noCache: true }); - } catch { - throw new ApprovalAuthorityError("PROFILE_FETCH_FAILED"); - } - if (actor.did !== publisherDid) { - throw new ApprovalAuthorityError("PROFILE_FETCH_FAILED"); - } - const requestedUrl = profileRecordUrl(actor.pds, publisherDid, packageSlug); - const resource = await fetchVerifiedResource(requestedUrl, { - fetch: (url, init) => fetchImplementation(url, init), - resolveHostname: (hostname) => resolvePublicHostname(hostname, fetchImplementation), - headerTimeoutMs: 10_000, - totalTimeoutMs: 30_000, - maxBytes: MAX_PROFILE_RESPONSE_BYTES, - maxRedirects: 1, - }); - if (!resource.success || resource.value.url.toString() !== requestedUrl.toString()) { - throw new ApprovalAuthorityError("PROFILE_FETCH_FAILED"); - } - let envelope: unknown; - try { - envelope = JSON.parse( - new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(resource.value.bytes), - ); - } catch { + record = await new DirectPdsClient({ + did: publisherDid, + fetch: createGuardedIdentityFetch(fetchImplementation), + ...(options.didDocumentResolver === undefined + ? {} + : { didDocumentResolver: options.didDocumentResolver }), + requestTimeoutMs: 30_000, + maxResponseBytes: MAX_PROFILE_RESPONSE_BYTES, + }).getPackageProfile(packageSlug); + } catch (error) { + if (error instanceof DirectPdsReadError || error instanceof TypeError) { + throw new ApprovalAuthorityError("PROFILE_FETCH_FAILED"); + } throw new ApprovalAuthorityError("PROFILE_FETCH_FAILED"); } const expectedUri = `at://${publisherDid}/${NSID.packageProfile}/${packageSlug}`; - if ( - !isRecord(envelope) || - envelope["uri"] !== expectedUri || - typeof envelope["cid"] !== "string" || - !("value" in envelope) - ) { - throw new ApprovalAuthorityError("PROFILE_FETCH_FAILED"); - } - const profile = safeParse(PackageProfile.mainSchema, envelope["value"]); - if (!profile.ok || profile.value.id !== expectedUri) { + if (record.uri !== expectedUri || record.value.id !== expectedUri) { throw new ApprovalAuthorityError("PROFILE_FETCH_FAILED"); } - const rawExtension = profile.value.extensions?.[NSID.packageProfileExtension]; + const rawExtension = record.value.extensions?.[NSID.packageProfileExtension]; const extension = safeParse(PackageProfileExtension.mainSchema, rawExtension); if (!extension.ok) throw new ApprovalAuthorityError("PROFILE_FETCH_FAILED"); const approverDids = extension.value.releasePolicy?.approvers ?? []; @@ -328,7 +294,7 @@ export async function loadCurrentApprovalPolicy( throw new ApprovalAuthorityError("PROFILE_FETCH_FAILED"); } return { - profileCid: envelope["cid"], + profileCid: record.cid, approverDids: [...approverDids].toSorted(), }; } diff --git a/apps/release-service/src/approvals/decision-routes.ts b/apps/release-service/src/approvals/decision-routes.ts index f6d8c6dc5e..a38e148ca8 100644 --- a/apps/release-service/src/approvals/decision-routes.ts +++ b/apps/release-service/src/approvals/decision-routes.ts @@ -426,7 +426,7 @@ export async function handleGetApproval( publisherDid(request), intentId(params), ); - await verifyCurrentApprover(loaded.evidence, session.approverDid); + await verifyCurrentApprover(loaded.evidence, loaded.approverDids, session.approverDid); const policyDecision = await env.PUBLISHER_DO.getByName( loaded.evidence.publisherDid, ).getVerificationStep(loaded.evidence.publisherDid, loaded.intent.id, "policy-decision"); @@ -479,7 +479,7 @@ export async function handleBeginApprovalDecision( if (loaded.intent.state !== "awaiting_approval") { throw new ApprovalAuthorityError("INTENT_NOT_APPROVABLE"); } - await verifyCurrentApprover(loaded.evidence, session.approverDid); + await verifyCurrentApprover(loaded.evidence, loaded.approverDids, session.approverDid); const result = await beginApprovalDecision( env.APPROVER_DO.getByName(session.approverDid), { @@ -529,7 +529,7 @@ export async function handleCompleteApprovalDecision( throw new ApprovalAuthorityError("INTENT_NOT_APPROVABLE"); } if (!alreadyApplied) { - await verifyCurrentApprover(loaded.evidence, session.approverDid); + await verifyCurrentApprover(loaded.evidence, loaded.approverDids, session.approverDid); } const result = await completeApprovalDecision( env.APPROVER_DO.getByName(session.approverDid), @@ -563,7 +563,7 @@ export async function handleCompleteApprovalDecision( } return apiSuccess({ receipt: result.receipt, intent: loaded.intent }, requestId); } - await verifyCurrentApprover(loaded.evidence, session.approverDid); + await verifyCurrentApprover(loaded.evidence, loaded.approverDids, session.approverDid); if (loaded.intent.expiresAt <= Date.now()) { throw new ApprovalAuthorityError("INTENT_NOT_APPROVABLE"); } diff --git a/apps/release-service/src/verification/pds.ts b/apps/release-service/src/verification/pds.ts index d1a40b8026..920ae0f764 100644 --- a/apps/release-service/src/verification/pds.ts +++ b/apps/release-service/src/verification/pds.ts @@ -1,6 +1,7 @@ import type { ActorResolver } from "@atcute/identity-resolver"; import { isDid } from "@atcute/lexicons/syntax"; import { + DEFAULT_DIRECT_PDS_MAX_RESPONSE_BYTES, DirectPdsClient, DirectPdsReadError, type DirectPdsDidDocumentResolver, @@ -15,8 +16,7 @@ import { createWorkerActorResolver } from "../oauth/custody.js"; const DNS_ENDPOINT = "https://cloudflare-dns.com/dns-query"; const MAX_DNS_BYTES = 64 * 1024; const MAX_PDS_RESPONSE_BYTES = 512 * 1024; -const MAX_RELEASE_PAGES = 100; -const PAGE_LIMIT = 100; +const MAX_REPO_EXPORT_RESPONSE_BYTES = DEFAULT_DIRECT_PDS_MAX_RESPONSE_BYTES; const PACKAGE_SLUG_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/; const VERSION_PATTERN = /^[0-9A-Za-z][0-9A-Za-z.-]{0,127}$/; const UPSTREAM_STATUS_HEADER = "x-emdash-upstream-status"; @@ -162,7 +162,10 @@ async function guardedJson(url: URL, fetchImplementation: typeof fetch): Promise } } -function guardedFetch(fetchImplementation: typeof fetch): typeof fetch { +function guardedFetch( + fetchImplementation: typeof fetch, + maximumBytes = MAX_PDS_RESPONSE_BYTES, +): typeof fetch { return async (input, init) => { const url = new URL(input instanceof Request ? input.url : input.toString()); const method = init?.method ?? (input instanceof Request ? input.method : "GET"); @@ -187,7 +190,7 @@ function guardedFetch(fetchImplementation: typeof fetch): typeof fetch { resolveHostname: (hostname) => resolvePublicHostname(hostname, fetchImplementation), headerTimeoutMs: 10_000, totalTimeoutMs: 30_000, - maxBytes: MAX_PDS_RESPONSE_BYTES, + maxBytes: maximumBytes, maxRedirects: 1, }); if (!resource.success || resource.value.url.toString() !== url.toString()) { @@ -305,21 +308,35 @@ function guardedIdentityFetch(fetchImplementation: typeof fetch): typeof fetch { }; } -async function getProfile( +async function getPackageRepository( publisherDid: string, packageSlug: string, fetchImplementation: typeof fetch, didDocumentResolver?: DirectPdsDidDocumentResolver, -): Promise { +): Promise<{ + profile: AuthoritativeRecord; + releases: readonly AuthoritativeRecord[]; +}> { try { - const record = await new DirectPdsClient({ + const repository = await new DirectPdsClient({ did: publisherDid, - fetch: guardedFetch(fetchImplementation), + fetch: guardedFetch(fetchImplementation, MAX_REPO_EXPORT_RESPONSE_BYTES), ...(didDocumentResolver === undefined ? {} : { didDocumentResolver }), requestTimeoutMs: 30_000, - maxResponseBytes: MAX_PDS_RESPONSE_BYTES, - }).getPackageProfile(packageSlug); - return { uri: record.uri, cid: record.cid, value: record.value }; + maxResponseBytes: MAX_REPO_EXPORT_RESPONSE_BYTES, + }).getPackageRepository(packageSlug); + return { + profile: { + uri: repository.profile.uri, + cid: repository.profile.cid, + value: repository.profile.value, + }, + releases: repository.releases.map((record) => ({ + uri: record.uri, + cid: record.cid, + value: record.value, + })), + }; } catch (error) { if (error instanceof DirectPdsReadError) { if ( @@ -328,17 +345,17 @@ async function getProfile( error.code === "DID_SIGNING_KEY_INVALID" || error.code === "DID_SIGNING_KEY_MISSING" || error.code === "PDS_ENDPOINT_INVALID" || - error.code === "PDS_ENDPOINT_MISSING" + error.code === "PDS_ENDPOINT_MISSING" || + error.code === "REPOSITORY_NOT_FOUND" ) { throw new PublisherSnapshotError("PUBLISHER_IDENTITY_INVALID"); } - if ( - error.code === "PROFILE_LEXICON_INVALID" || - error.code === "RECORD_NOT_FOUND" || - error.code === "RECORD_PROOF_INVALID" - ) { + if (error.code === "PROFILE_LEXICON_INVALID" || error.code === "RECORD_NOT_FOUND") { throw new PublisherSnapshotError("PROFILE_INVALID"); } + if (error.code === "RELEASE_LEXICON_INVALID" || error.code === "RECORD_PROOF_INVALID") { + throw new PublisherSnapshotError("RELEASE_LIST_INVALID"); + } } if (error instanceof TypeError) { throw new PublisherSnapshotError("PUBLISHER_IDENTITY_INVALID"); @@ -376,50 +393,6 @@ async function getRelease( return record; } -async function listPackageReleases( - pds: string, - publisherDid: string, - packageSlug: string, - fetchImplementation: typeof fetch, -): Promise { - const records: AuthoritativeRecord[] = []; - const cursors = new Set(); - const collectionUriPrefix = `at://${publisherDid}/${NSID.packageRelease}/`; - const packageUriPrefix = `${collectionUriPrefix}${packageSlug}:`; - let cursor: string | null = null; - for (let page = 0; page < MAX_RELEASE_PAGES; page += 1) { - const url = pdsXrpcUrl(pds, "com.atproto.repo.listRecords"); - url.searchParams.set("repo", publisherDid); - url.searchParams.set("collection", NSID.packageRelease); - url.searchParams.set("limit", String(PAGE_LIMIT)); - if (cursor !== null) url.searchParams.set("cursor", cursor); - const parsed = await guardedJson(url, fetchImplementation); - if (!isRecord(parsed) || !Array.isArray(parsed["records"])) { - throw new PublisherSnapshotError("RELEASE_LIST_INVALID"); - } - for (const value of parsed["records"]) { - const record = parseRecord(value); - if (!record || !record.uri.startsWith(collectionUriPrefix)) { - throw new PublisherSnapshotError("RELEASE_LIST_INVALID"); - } - if (!record.uri.startsWith(packageUriPrefix)) continue; - records.push(record); - } - if (parsed["cursor"] === undefined) return records; - if ( - typeof parsed["cursor"] !== "string" || - parsed["cursor"].length < 1 || - parsed["cursor"].length > 4096 || - cursors.has(parsed["cursor"]) - ) { - throw new PublisherSnapshotError("RELEASE_LIST_INVALID"); - } - cursor = parsed["cursor"]; - cursors.add(cursor); - } - throw new PublisherSnapshotError("RELEASE_LIST_INVALID"); -} - function releaseVersion(record: AuthoritativeRecord, publisherDid: string, packageSlug: string) { const prefix = `at://${publisherDid}/${NSID.packageRelease}/${packageSlug}:`; if (!record.uri.startsWith(prefix)) throw new PublisherSnapshotError("RELEASE_LIST_INVALID"); @@ -444,11 +417,12 @@ export async function readPublisherVerificationSnapshot( throw new PublisherSnapshotError("PUBLISHER_IDENTITY_INVALID"); } const fetchImplementation = options.fetch ?? globalThis.fetch; - const pds = await resolvePublisherPds(publisherDid, options); - const [profile, releases] = await Promise.all([ - getProfile(publisherDid, packageSlug, fetchImplementation, options.didDocumentResolver), - listPackageReleases(pds, publisherDid, packageSlug, fetchImplementation), - ]); + const { profile, releases } = await getPackageRepository( + publisherDid, + packageSlug, + fetchImplementation, + options.didDocumentResolver, + ); const proposedRkey = `${packageSlug}:${version}`; let baseline: AuthoritativeRecord | null = null; let baselineVersion: string | null = null; diff --git a/apps/release-service/test/approval-authority.test.ts b/apps/release-service/test/approval-authority.test.ts index e0f737b464..f8757b412b 100644 --- a/apps/release-service/test/approval-authority.test.ts +++ b/apps/release-service/test/approval-authority.test.ts @@ -1,4 +1,4 @@ -import type { ActorResolver } from "@atcute/identity-resolver"; +import type { DirectPdsDidDocumentResolver } from "@emdash-cms/registry-client/direct-pds"; import { NSID } from "@emdash-cms/registry-lexicons"; import { reset, runInDurableObject } from "cloudflare:test"; import { env } from "cloudflare:workers"; @@ -16,7 +16,9 @@ const PUBLISHER_DID = "did:plc:publisher"; const APPROVER_DID = "did:plc:approver"; const INTENT_ID = "01JABCDEFGHJKMNPQRSTVWXYZ0"; const NOW = 1_800_000_000_000; -const PROFILE_CID = "bafyreib3p6qexampleprofilecid"; +const PROFILE_CID = "bafyreie3bcpcntqlswxk32ibe4v2cvhhvaq7gcx6css2vuzasirgk3xmly"; +const PROFILE_PROOF = + "OqJlcm9vdHOB2CpYJQABcRIguIOtOxeeD6PfhhwV1Tbcy0g1a5TRE+tSQA0QlhEj6FRndmVyc2lvbgHQAQFxEiC4g607F54Po9+GHBXVNtzLSDVrlNET61JADRCWESPoVKZjZGlkcWRpZDpwbGM6cHVibGlzaGVyY3Jldm0zbXVqa3M1bG53azI0Y3NpZ1hA4lFxxn7YC9lg4/mEb9l7Lb+uN+8EzZvH6XsUrpCbtNg+kr0+VIQArQba1jZajQL4pc1IeP6Oq1KRWPcVGKZpTGRkYXRh2CpYJQABcRIg5rQ4qhRh79SdMF1zLkkklmnQjgkMGK7mrU2HiQJnRYtkcHJldvZndmVyc2lvbgOXAgFxEiDmtDiqFGHv1J0wXXMuSSSWadCOCQwYruatTYeJAmdFi6JhZYOkYWtYMmNvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucHJvZmlsZS9nYWxsZXJ5YXAAYXT2YXbYKlglAAFxEiCbCJ4mzguVrq3pAScroVTnqCHzCv4UparTIJIiZW7sXqRha1VyZWxlYXNlL2dhbGxlcnk6MS4wLjBhcBgjYXT2YXbYKlglAAFxEiAVgbNAcHSSrRFFo3roii2+pXMBVGSC2AOYbrJfAzWLwqRha0M3LjBhcBg1YXT2YXbYKlglAAFxEiBhFDeoEsxJobozp3Y26kHUHywaIc1posb8QrJvJtD0DWFs9roEAXESIJsInibOC5WurekBJyuhVOeoIfMK/hSlqtMgkiJlbuxep2JpZHhJYXQ6Ly9kaWQ6cGxjOnB1Ymxpc2hlci9jb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnByb2ZpbGUvZ2FsbGVyeWR0eXBlbWVtZGFzaC1wbHVnaW5lJHR5cGV4KmNvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucHJvZmlsZWdhdXRob3JzgaFkbmFtZWlQdWJsaXNoZXJnbGljZW5zZWNNSVRoc2VjdXJpdHmBoWVlbWFpbHRzZWN1cml0eUBleGFtcGxlLmNvbWpleHRlbnNpb25zoXgzY29tLmVtZGFzaGNtcy5leHBlcmltZW50YWwucGFja2FnZS5wcm9maWxlRXh0ZW5zaW9uo2UkdHlwZXgzY29tLmVtZGFzaGNtcy5leHBlcmltZW50YWwucGFja2FnZS5wcm9maWxlRXh0ZW5zaW9uanJlcG9zaXRvcnl4JWh0dHBzOi8vZ2l0aHViLmNvbS9lbWRhc2gtY21zL2dhbGxlcnltcmVsZWFzZVBvbGljeaNlJHR5cGV4QWNvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucHJvZmlsZUV4dGVuc2lvbiNyZWxlYXNlUG9saWN5aWFwcHJvdmVyc4FwZGlkOnBsYzphcHByb3Zlcmxjb25maXJtYXRpb25mYWx3YXlz"; const EVIDENCE: ApprovalEvidence = { intentId: INTENT_ID, @@ -110,13 +112,27 @@ async function createAwaitingApprovalIntent() { }); } -function actorResolver(): ActorResolver { +function proofResolver(): DirectPdsDidDocumentResolver { return { - resolve: async () => ({ - did: PUBLISHER_DID, - handle: "publisher.example.com", - pds: "https://pds.example.com", - }), + resolve: () => + Promise.resolve({ + id: PUBLISHER_DID, + verificationMethod: [ + { + id: `${PUBLISHER_DID}#atproto`, + type: "Multikey", + controller: PUBLISHER_DID, + publicKeyMultibase: "zDnaejExR13CZ7p99ojitvboj6ZaYzxhMDqJwnZd7APbohKkR", + }, + ], + service: [ + { + id: "#atproto_pds", + type: "AtprotoPersonalDataServer", + serviceEndpoint: "https://pds.example.com", + }, + ], + }), }; } @@ -137,8 +153,15 @@ function profileValue(approvers: string[] = [APPROVER_DID]) { }; } -function authorityFetch(options: { approvers?: string[]; cid?: string; address?: string } = {}) { - return async (input: RequestInfo | URL): Promise => { +function authorityFetch( + options: { + approvers?: string[]; + cid?: string; + address?: string; + requireCarAccept?: boolean; + } = {}, +) { + return async (input: RequestInfo | URL, init?: RequestInit): Promise => { const url = new URL(input instanceof Request ? input.url : input.toString()); if (url.hostname === "cloudflare-dns.com") { return Response.json({ @@ -156,6 +179,18 @@ function authorityFetch(options: { approvers?: string[]; cid?: string; address?: value: profileValue(options.approvers), }); } + if (url.hostname === "pds.example.com" && url.pathname === "/xrpc/com.atproto.sync.getRecord") { + if ( + options.requireCarAccept && + new Headers(init?.headers).get("accept") !== "application/vnd.ipld.car" + ) { + return Response.json({ error: "NotAcceptable" }, { status: 406 }); + } + const bytes = Uint8Array.from(atob(PROFILE_PROOF), (character) => character.charCodeAt(0)); + return new Response(bytes, { + headers: { "content-type": "application/vnd.ipld.car" }, + }); + } throw new Error(`Unexpected request: ${url.toString()}`); }; } @@ -172,6 +207,7 @@ describe("approval authority", () => { loadApprovalIntent(env.PUBLISHER_DO, PUBLISHER_DID, INTENT_ID), ).resolves.toMatchObject({ evidence: EVIDENCE, + approverDids: [APPROVER_DID], approvalGeneration: 4, intent: { state: "awaiting_approval" }, }); @@ -207,46 +243,55 @@ describe("approval authority", () => { ).rejects.toMatchObject({ code: "INTENT_NOT_APPROVABLE" }); }); - it("accepts only a currently listed approver at the exact profile CID", async () => { + it("accepts only an immutable approver at the exact proof-verified profile CID", async () => { await expect( - verifyCurrentApprover(EVIDENCE, APPROVER_DID, { - actorResolver: actorResolver(), + verifyCurrentApprover(EVIDENCE, [APPROVER_DID], APPROVER_DID, { + didDocumentResolver: proofResolver(), fetch: authorityFetch(), }), ).resolves.toBeUndefined(); await expect( - verifyCurrentApprover(EVIDENCE, APPROVER_DID, { - actorResolver: actorResolver(), - fetch: authorityFetch({ approvers: ["did:plc:other"] }), + verifyCurrentApprover(EVIDENCE, ["did:plc:other"], APPROVER_DID, { + didDocumentResolver: proofResolver(), + fetch: authorityFetch({ approvers: [APPROVER_DID] }), }), ).rejects.toMatchObject({ code: "APPROVER_NOT_AUTHORIZED" }); await expect( - verifyCurrentApprover(EVIDENCE, APPROVER_DID, { - actorResolver: actorResolver(), - fetch: authorityFetch({ cid: "bafyreib3p6qchangedprofilecid" }), + verifyCurrentApprover(EVIDENCE, [APPROVER_DID], APPROVER_DID, { + didDocumentResolver: proofResolver(), + fetch: authorityFetch({ approvers: ["did:plc:attacker"], cid: PROFILE_CID }), }), - ).rejects.toMatchObject({ code: "PROFILE_CHANGED" }); + ).resolves.toBeUndefined(); }); it("loads the current signed approver policy for publisher status views", async () => { await expect( loadCurrentApprovalPolicy(PUBLISHER_DID, "gallery", { - actorResolver: actorResolver(), + didDocumentResolver: proofResolver(), fetch: authorityFetch(), }), ).resolves.toEqual({ profileCid: PROFILE_CID, approverDids: [APPROVER_DID] }); await expect( loadCurrentApprovalPolicy(PUBLISHER_DID, "gallery", { - actorResolver: actorResolver(), + didDocumentResolver: proofResolver(), fetch: authorityFetch({ approvers: [APPROVER_DID, APPROVER_DID] }), }), - ).rejects.toMatchObject({ code: "PROFILE_FETCH_FAILED" }); + ).resolves.toEqual({ profileCid: EVIDENCE.profileCid, approverDids: [APPROVER_DID] }); + }); + + it("requests the current profile as a repository proof CAR", async () => { + await expect( + loadCurrentApprovalPolicy(PUBLISHER_DID, "gallery", { + didDocumentResolver: proofResolver(), + fetch: authorityFetch({ requireCarAccept: true }), + }), + ).resolves.toEqual({ profileCid: PROFILE_CID, approverDids: [APPROVER_DID] }); }); it("rejects private PDS resolution before fetching the record", async () => { await expect( - verifyCurrentApprover(EVIDENCE, APPROVER_DID, { - actorResolver: actorResolver(), + verifyCurrentApprover(EVIDENCE, [APPROVER_DID], APPROVER_DID, { + didDocumentResolver: proofResolver(), fetch: authorityFetch({ address: "10.0.0.1" }), }), ).rejects.toBeInstanceOf(ApprovalAuthorityError); @@ -268,6 +313,7 @@ describe("approval authority", () => { await expect( verifyCurrentApprover( { ...EVIDENCE, publisherDid: "did:web:publisher.example.com" }, + [APPROVER_DID], APPROVER_DID, { fetch }, ), diff --git a/apps/release-service/test/approval-decision-routes.test.ts b/apps/release-service/test/approval-decision-routes.test.ts index b3fb365095..1245b74d73 100644 --- a/apps/release-service/test/approval-decision-routes.test.ts +++ b/apps/release-service/test/approval-decision-routes.test.ts @@ -13,9 +13,12 @@ import { TEST_BINDINGS } from "./fixtures/oauth.js"; const ORIGIN = "https://release.example.com"; const PUBLISHER_DID = "did:web:publisher.example.com"; const APPROVER_DID = "did:plc:approver"; +const ATTACKER_DID = "did:plc:attacker"; const INTENT_ID = "01JABCDEFGHJKMNPQRSTVWXYZ0"; const CREDENTIAL_ID = "approval-credential"; -const PROFILE_CID = "bafyreib3p6qexampleprofilecid"; +const PROFILE_CID = "bafyreielha65mr3o2wgupjyglbhdujvq3k7isfkz5uejbjnevnypmdk2wi"; +const PROFILE_PROOF = + "OqJlcm9vdHOB2CpYJQABcRIgR1ivuJdVA3NEw+prJcQhhJXHGT6zcyewmDKjkr37ZjtndmVyc2lvbgHdAQFxEiBHWK+4l1UDc0TD6mslxCGElccZPrNzJ7CYMqOSvftmO6ZjZGlkeB1kaWQ6d2ViOnB1Ymxpc2hlci5leGFtcGxlLmNvbWNyZXZtM211amtzNW03ajIyNGNzaWdYQDTT+fQkfkx6l1l21oVamQWReNbzhS8P2OIbYdL2HmLqbDtCJ13YECxuhEtcDOB598dPFcWGruof+EgnC220ivBkZGF0YdgqWCUAAXESIPRNAAbvLpqyxQsY9xwRwEoJlpJUttI1VoLAT7F1PUGRZHByZXb2Z3ZlcnNpb24DkwEBcRIg9E0ABu8umrLFCxj3HBHASgmWklS20jVWgsBPsXU9QZGiYWWBpGFrWDJjb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnByb2ZpbGUvZ2FsbGVyeWFwAGF09mF22CpYJQABcRIgizg91kdu1Y1HpwZYTjomsNq+iRVZ7QiQpaSrcPYNWrJhbPbGBAFxEiCLOD3WR27VjUenBlhOOiaw2r6JFVntCJClpKtw9g1asqdiaWR4VWF0Oi8vZGlkOndlYjpwdWJsaXNoZXIuZXhhbXBsZS5jb20vY29tLmVtZGFzaGNtcy5leHBlcmltZW50YWwucGFja2FnZS5wcm9maWxlL2dhbGxlcnlkdHlwZW1lbWRhc2gtcGx1Z2luZSR0eXBleCpjb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnByb2ZpbGVnYXV0aG9yc4GhZG5hbWVpUHVibGlzaGVyZ2xpY2Vuc2VjTUlUaHNlY3VyaXR5gaFlZW1haWx0c2VjdXJpdHlAZXhhbXBsZS5jb21qZXh0ZW5zaW9uc6F4M2NvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucHJvZmlsZUV4dGVuc2lvbqNlJHR5cGV4M2NvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucHJvZmlsZUV4dGVuc2lvbmpyZXBvc2l0b3J5eCVodHRwczovL2dpdGh1Yi5jb20vZW1kYXNoLWNtcy9nYWxsZXJ5bXJlbGVhc2VQb2xpY3mjZSR0eXBleEFjb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnByb2ZpbGVFeHRlbnNpb24jcmVsZWFzZVBvbGljeWlhcHByb3ZlcnOBcGRpZDpwbGM6YXBwcm92ZXJsY29uZmlybWF0aW9uZmFsd2F5cw=="; const NOW = 1_800_000_000_000; const WORKLOAD_IDENTITY = { issuer: "github-actions", @@ -117,8 +120,8 @@ function cookieValue(header: string): string { return header.split(";", 1)[0] ?? ""; } -async function sessionHeaders() { - const session = await createApproverApplicationSession(env.APPROVER_DO, APPROVER_DID); +async function sessionHeaders(approverDid: `did:${string}:${string}` = APPROVER_DID) { + const session = await createApproverApplicationSession(env.APPROVER_DO, approverDid); const csrf = cookieValue(session.setCookieHeaders[1]).split("=", 2)[1] ?? ""; return { cookie: session.setCookieHeaders.map(cookieValue).join("; "), @@ -238,6 +241,14 @@ function approvalNetwork(state: { approvers: string[]; cid: string }) { if (url.hostname === "publisher.example.com" && url.pathname === "/.well-known/did.json") { return Response.json({ id: PUBLISHER_DID, + verificationMethod: [ + { + id: `${PUBLISHER_DID}#atproto`, + type: "Multikey", + controller: PUBLISHER_DID, + publicKeyMultibase: "zDnaeeC67nTB5vVpkk4JhzBKcMpXzBQ6XrmihS6cd2wWBAmGK", + }, + ], service: [ { id: "#atproto_pds", @@ -260,6 +271,12 @@ function approvalNetwork(state: { approvers: string[]; cid: string }) { value: profileValue(state.approvers), }); } + if (url.hostname === "pds.example" && url.pathname === "/xrpc/com.atproto.sync.getRecord") { + const bytes = Uint8Array.from(atob(PROFILE_PROOF), (character) => character.charCodeAt(0)); + return new Response(bytes, { + headers: { "content-type": "application/vnd.ipld.car" }, + }); + } throw new Error(`Unexpected request: ${url.toString()}`); }; } @@ -315,9 +332,9 @@ function assertion( }; } -async function enrolCredential() { +async function enrolCredential(approverDid: `did:${string}:${string}` = APPROVER_DID) { const key = createCredential(); - await env.APPROVER_DO.getByName(APPROVER_DID).enrolCredential(APPROVER_DID, { + await env.APPROVER_DO.getByName(approverDid).enrolCredential(approverDid, { credentialId: CREDENTIAL_ID, publicKey: key.publicKey, algorithm: -7, @@ -478,7 +495,7 @@ describe("approval decision routes", () => { }, ); - it("rejects an unlisted approver before creating a challenge", async () => { + it("ignores an unsigned profile envelope that omits an immutable approver", async () => { await createAwaitingIntent(); await enrolCredential(); vi.stubGlobal("fetch", approvalNetwork({ approvers: ["did:plc:other"], cid: PROFILE_CID })); @@ -491,11 +508,34 @@ describe("approval decision routes", () => { }), bindings(), ); + expect(response.status).toBe(200); + }); + + it("rejects an attacker passkey even when an unsigned profile envelope substitutes their DID", async () => { + await createAwaitingIntent(); + await enrolCredential(ATTACKER_DID); + vi.stubGlobal("fetch", approvalNetwork({ approvers: [ATTACKER_DID], cid: PROFILE_CID })); + const resource = `${ORIGIN}/v1/approvals/${INTENT_ID}/options?publisher=${encodeURIComponent(PUBLISHER_DID)}`; + const response = await handleRequest( + new Request(resource, { + method: "POST", + headers: { + ...(await sessionHeaders(ATTACKER_DID)), + "content-type": "application/json", + }, + body: JSON.stringify({ decision: "approve" }), + }), + bindings(), + ); + expect(response.status).toBe(404); await expect(response.json()).resolves.toMatchObject({ error: { code: "NOT_FOUND" } }); + await expect( + env.PUBLISHER_DO.getByName(PUBLISHER_DID).getIntent(PUBLISHER_DID, INTENT_ID), + ).resolves.toMatchObject({ state: "awaiting_approval" }); }); - it("rejects profile changes and non-user-verified assertions without transitioning", async () => { + it("rejects non-user-verified assertions without transitioning", async () => { await createAwaitingIntent(); const key = await enrolCredential(); const network = { approvers: [APPROVER_DID], cid: PROFILE_CID }; @@ -526,17 +566,6 @@ describe("approval decision routes", () => { ); expect(nonUv.status).toBe(400); - network.cid = "bafyreib3p6qchangedprofilecid"; - const changed = await handleRequest( - new Request(optionsUrl, { - method: "POST", - headers: { ...headers, "content-type": "application/json" }, - body: JSON.stringify({ decision: "approve" }), - }), - bindings(), - ); - expect(changed.status).toBe(409); - await expect(changed.json()).resolves.toMatchObject({ error: { code: "PROFILE_CHANGED" } }); await expect( env.PUBLISHER_DO.getByName(PUBLISHER_DID).getIntent(PUBLISHER_DID, INTENT_ID), ).resolves.toMatchObject({ diff --git a/apps/release-service/test/release-intent-workflow.test.ts b/apps/release-service/test/release-intent-workflow.test.ts index 1a77bad3f0..b478ec5351 100644 --- a/apps/release-service/test/release-intent-workflow.test.ts +++ b/apps/release-service/test/release-intent-workflow.test.ts @@ -17,7 +17,6 @@ import { persistWorkloadStagedArtifact, workloadArtifactSourceUrl, } from "../src/publishing/workload-staging.js"; -import type { AuthoritativeRecord } from "../src/verification/pds.js"; import { restartReleaseIntentWorkflow, startReleaseIntentWorkflow, @@ -71,6 +70,11 @@ const WORKLOAD_IDENTITY: VerifiedWorkloadIdentity = { issuedAt: 1_800_000_000, expiresAt: 1_800_000_300, }; +const WORKFLOW_REPOSITORY_SIGNING_KEY = "zDnaehJ198TPtSvvRovBzG7rydLgzEz8duqMfnqDGfN4RheUG"; +const WORKFLOW_REPOSITORY_ABSENT = + "OqJlcm9vdHOB2CpYJQABcRIgB9itCKrPZ7cyFm11WUh44VKapmCsl6XynhUU19sBqhlndmVyc2lvbgHdAQFxEiAH2K0Iqs9ntzIWbXVZSHjhUpqmYKyXpfKeFRTX2wGqGaZjZGlkeB1kaWQ6d2ViOnB1Ymxpc2hlci5leGFtcGxlLmNvbWNyZXZtM211amxwdG8zdWMybmNzaWdYQNXHC6vzE2Pg+cR3/eWY+iuEVqbWhQWhM0KeVJHG4mwjRSQIfCZvdhTM6nBmf7IFXpsi4oSNXfqwEjZOE2qkMURkZGF0YdgqWCUAAXESICPWWGKAvX12s+8YBNB6iLwFl8YMr6smSZpFoaG8aBsnZHByZXb2Z3ZlcnNpb24DkwEBcRIgI9ZYYoC9fXaz7xgE0HqIvAWXxgyvqyZJmkWhobxoGyeiYWWBpGFrWDJjb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnByb2ZpbGUvZ2FsbGVyeWFwAGF09mF22CpYJQABcRIg75HAxLI29zFxT2IAMP+6xED3Uxy3mslLTuujJkBV1nphbPbQAwFxEiDvkcDEsjb3MXFPYgAw/7rEQPdTHLeayUtO66MmQFXWeqhiaWR4VWF0Oi8vZGlkOndlYjpwdWJsaXNoZXIuZXhhbXBsZS5jb20vY29tLmVtZGFzaGNtcy5leHBlcmltZW50YWwucGFja2FnZS5wcm9maWxlL2dhbGxlcnlkbmFtZWdHYWxsZXJ5ZHR5cGVtZW1kYXNoLXBsdWdpbmUkdHlwZXgqY29tLmVtZGFzaGNtcy5leHBlcmltZW50YWwucGFja2FnZS5wcm9maWxlZ2F1dGhvcnOBoWRuYW1lcUV4YW1wbGUgUHVibGlzaGVyZ2xpY2Vuc2VjTUlUaHNlY3VyaXR5gaFlZW1haWx0c2VjdXJpdHlAZXhhbXBsZS5jb21qZXh0ZW5zaW9uc6F4M2NvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucHJvZmlsZUV4dGVuc2lvbqJlJHR5cGV4M2NvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucHJvZmlsZUV4dGVuc2lvbmpyZXBvc2l0b3J5eCJodHRwczovL2dpdGh1Yi5jb20vZXhhbXBsZS9nYWxsZXJ5"; +const WORKFLOW_REPOSITORY_PRESENT = + "OqJlcm9vdHOB2CpYJQABcRIgWgszmOUMvR7oP5UWgDQlhH4/SzqVgHvfnAuyV0d/QFxndmVyc2lvbgHdAQFxEiBaCzOY5Qy9Hug/lRaANCWEfj9LOpWAe9+cC7JXR39AXKZjZGlkeB1kaWQ6d2ViOnB1Ymxpc2hlci5leGFtcGxlLmNvbWNyZXZtM211amxwdG9lbmsybmNzaWdYQOGuG+Xmqsl70lHcF35wqZb5Bfw7MKmWfs3/UyIdpb8JMq9NaSX/+eLAOeS5A2NcFvSxDZUMd9OA33nZ0C4KRf9kZGF0YdgqWCUAAXESIACaXXvgPQS6nqPkANm3i+c4LtA1ejLBGOPmjUw7BHWAZHByZXb2Z3ZlcnNpb24DwQEBcRIgAJpde+A9BLqeo+QA2beL5zgu0DV6MsEY4+aNTDsEdYCiYWWBpGFrWDhjb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnJlbGVhc2UvZ2FsbGVyeToxLjIuM2FwAGF09mF22CpYJQABcRIgmH2tx7Vra7YsQYhvZLzp7PY930i1mrqsy0iZyPqHIWNhbNgqWCUAAXESICPWWGKAvX12s+8YBNB6iLwFl8YMr6smSZpFoaG8aBsnkwEBcRIgI9ZYYoC9fXaz7xgE0HqIvAWXxgyvqyZJmkWhobxoGyeiYWWBpGFrWDJjb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnByb2ZpbGUvZ2FsbGVyeWFwAGF09mF22CpYJQABcRIg75HAxLI29zFxT2IAMP+6xED3Uxy3mslLTuujJkBV1nphbPabAwFxEiCYfa3HtWtrtixBiG9kvOns9j3fSLWauqzLSJnI+ochY6VlJHR5cGV4KmNvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucmVsZWFzZWdwYWNrYWdlZ2dhbGxlcnlndmVyc2lvbmUxLjIuM2lhcnRpZmFjdHOhZ3BhY2thZ2WjY3VybHgfaHR0cHM6Ly9leGFtcGxlLmNvbS9nYWxsZXJ5LnRnemhjaGVja3N1bXg4YmNpcWhhenBsNXcycmE3NDJuZ2plend4b3k0cDc0cDJleWlmdG5uaHljc29mYW53bWRyZXppdHlrY29udGVudFR5cGVwYXBwbGljYXRpb24vZ3ppcGpleHRlbnNpb25zoXgzY29tLmVtZGFzaGNtcy5leHBlcmltZW50YWwucGFja2FnZS5yZWxlYXNlRXh0ZW5zaW9uomUkdHlwZXgzY29tLmVtZGFzaGNtcy5leHBlcmltZW50YWwucGFja2FnZS5yZWxlYXNlRXh0ZW5zaW9ubmRlY2xhcmVkQWNjZXNzoNADAXESIO+RwMSyNvcxcU9iADD/usRA91Mct5rJS07royZAVdZ6qGJpZHhVYXQ6Ly9kaWQ6d2ViOnB1Ymxpc2hlci5leGFtcGxlLmNvbS9jb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnByb2ZpbGUvZ2FsbGVyeWRuYW1lZ0dhbGxlcnlkdHlwZW1lbWRhc2gtcGx1Z2luZSR0eXBleCpjb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnByb2ZpbGVnYXV0aG9yc4GhZG5hbWVxRXhhbXBsZSBQdWJsaXNoZXJnbGljZW5zZWNNSVRoc2VjdXJpdHmBoWVlbWFpbHRzZWN1cml0eUBleGFtcGxlLmNvbWpleHRlbnNpb25zoXgzY29tLmVtZGFzaGNtcy5leHBlcmltZW50YWwucGFja2FnZS5wcm9maWxlRXh0ZW5zaW9uomUkdHlwZXgzY29tLmVtZGFzaGNtcy5leHBlcmltZW50YWwucGFja2FnZS5wcm9maWxlRXh0ZW5zaW9uanJlcG9zaXRvcnl4Imh0dHBzOi8vZ2l0aHViLmNvbS9leGFtcGxlL2dhbGxlcnk="; function writeUint24LittleEndian(bytes: Uint8Array, offset: number, value: number): void { bytes[offset] = value & 0xff; @@ -153,8 +157,12 @@ const PROFILE_PROOF = const APPROVAL_PROFILE_PROOF = "OqJlcm9vdHOB2CpYJQABcRIgt4Be/ylpOhy2o33XFr7JATwH2VmFRzL6VB4p2I0MSzVndmVyc2lvbgHdAQFxEiC3gF7/KWk6HLajfdcWvskBPAfZWYVHMvpUHinYjQxLNaZjZGlkeB1kaWQ6d2ViOnB1Ymxpc2hlci5leGFtcGxlLmNvbWNyZXZtM211NXFhZHR6Y2sybWNzaWdYQBg2vVFiuGjkb1Q9TukMNZFbFZ/xXo5d8a6UZnGNnq/FIGQMPMH+RiEl+yhSvATZ9KnIQ2ujZ5q5qkjKyu5t6XhkZGF0YdgqWCUAAXESIGduRlvZ/Lua96nilhYmPVcpLg+ZjEa4kIialhQmHwB0ZHByZXb2Z3ZlcnNpb24DkwEBcRIgZ25GW9n8u5r3qeKWFiY9VykuD5mMRriQiJqWFCYfAHSiYWWBpGFrWDJjb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnByb2ZpbGUvZ2FsbGVyeWFwAGF09mF22CpYJQABcRIgrKiSWBl9zSDvo1PXTnK3qUZGccnZeweHtjm0xemh2J5hbPaPBAFxEiCsqJJYGX3NIO+jU9dOcrepRkZxydl7B4e2ObTF6aHYnqhiaWR4VWF0Oi8vZGlkOndlYjpwdWJsaXNoZXIuZXhhbXBsZS5jb20vY29tLmVtZGFzaGNtcy5leHBlcmltZW50YWwucGFja2FnZS5wcm9maWxlL2dhbGxlcnlkbmFtZWdHYWxsZXJ5ZHR5cGVtZW1kYXNoLXBsdWdpbmUkdHlwZXgqY29tLmVtZGFzaGNtcy5leHBlcmltZW50YWwucGFja2FnZS5wcm9maWxlZ2F1dGhvcnOBoWRuYW1lcUV4YW1wbGUgUHVibGlzaGVyZ2xpY2Vuc2VjTUlUaHNlY3VyaXR5gaFlZW1haWx0c2VjdXJpdHlAZXhhbXBsZS5jb21qZXh0ZW5zaW9uc6F4M2NvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucHJvZmlsZUV4dGVuc2lvbqNlJHR5cGV4M2NvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucHJvZmlsZUV4dGVuc2lvbmpyZXBvc2l0b3J5eCJodHRwczovL2dpdGh1Yi5jb20vZXhhbXBsZS9nYWxsZXJ5bXJlbGVhc2VQb2xpY3miaWFwcHJvdmVyc4FwZGlkOnBsYzphcHByb3Zlcmxjb25maXJtYXRpb25mYWx3YXlz"; const ESCALATION_ONLY_PROFILE_PROOF = - "OqJlcm9vdHOB2CpYJQABcRIg9qJp06k9Bpe+jLKRiea83D3qgyuWI1PLK7ZIuHEC+z9ndmVyc2lvbgHdAQFxEiD2omnTqT0Gl76MspGJ5rzcPeqDK5YjU8srtki4cQL7P6ZjZGlkeB1kaWQ6d2ViOnB1Ymxpc2hlci5leGFtcGxlLmNvbWNyZXZtM211amt6dHZwamMyeGNzaWdYQCWWVx/ubT52wRrIeUXNKyG88VYq3qksj4dMdk1V+fmAeR9K1vt3fK1vmpfvG2eVBWEmfNkxExHnHal22FDev6JkZGF0YdgqWCUAAXESINF8ERKM5sXJ8zAdcMaArpcVuqgGvseuNQmec5MYvHnQZHByZXb2Z3ZlcnNpb24DkwEBcRIg0XwREozmxcnzMB1wxoCulxW6qAa+x641CZ5zkxi8edCiYWWBpGFrWDJjb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnByb2ZpbGUvZ2FsbGVyeWFwAGF09mF22CpYJQABcRIgN+KakpY3MooEiEzdUFA1gQ7vaaorJdv7QEQF4jxZ9t9hbPaYBAFxEiA34pqSljcyigSITN1QUDWBDu9pqisl2/tARAXiPFn236hiaWR4VWF0Oi8vZGlkOndlYjpwdWJsaXNoZXIuZXhhbXBsZS5jb20vY29tLmVtZGFzaGNtcy5leHBlcmltZW50YWwucGFja2FnZS5wcm9maWxlL2dhbGxlcnlkbmFtZWdHYWxsZXJ5ZHR5cGVtZW1kYXNoLXBsdWdpbmUkdHlwZXgqY29tLmVtZGFzaGNtcy5leHBlcmltZW50YWwucGFja2FnZS5wcm9maWxlZ2F1dGhvcnOBoWRuYW1lcUV4YW1wbGUgUHVibGlzaGVyZ2xpY2Vuc2VjTUlUaHNlY3VyaXR5gaFlZW1haWx0c2VjdXJpdHlAZXhhbXBsZS5jb21qZXh0ZW5zaW9uc6F4M2NvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucHJvZmlsZUV4dGVuc2lvbqNlJHR5cGV4M2NvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucHJvZmlsZUV4dGVuc2lvbmpyZXBvc2l0b3J5eCJodHRwczovL2dpdGh1Yi5jb20vZXhhbXBsZS9nYWxsZXJ5bXJlbGVhc2VQb2xpY3miaWFwcHJvdmVyc4FwZGlkOnBsYzphcHByb3Zlcmxjb25maXJtYXRpb25vZXNjYWxhdGlvbi1vbmx5"; -const ESCALATION_ONLY_SIGNING_KEY = "zDnaekRBnvWmwwGibpSzPHpH1rvvZZXB6APVS4tHEccxMBEDc"; + "OqJlcm9vdHOB2CpYJQABcRIgVDE0fJILp28OW3uFemvB8DupoEHR9qa10q/QWtIKJQhndmVyc2lvbgHdAQFxEiBUMTR8kgunbw5be4V6a8HwO6mgQdH2prXSr9Ba0golCKZjZGlkeB1kaWQ6d2ViOnB1Ymxpc2hlci5leGFtcGxlLmNvbWNyZXZtM211anN2aDR3bHMyNmNzaWdYQOBgyay0sEK8mN17Q8+ZpLzmhJJdeYdEvCT+9GqrnmJmKgcLAl2ebneSf9b9OXptMS6TI3gUZMRWvvpCxcpZqW5kZGF0YdgqWCUAAXESIJLdQkzZSPifehvezRHqWT2Orp2FN6WjFX/HFReaYBvAZHByZXb2Z3ZlcnNpb24DkwEBcRIgkt1CTNlI+J96G97NEepZPY6unYU3paMVf8cVF5pgG8CiYWWBpGFrWDJjb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnByb2ZpbGUvZ2FsbGVyeWFwAGF09mF22CpYJQABcRIgw0TlSwQE8Qi5DmeWDUCV1/wovIGUh847dRDPTil7SyRhbPbhBAFxEiDDROVLBATxCLkOZ5YNQJXX/Ci8gZSHzjt1EM9OKXtLJKhiaWR4VWF0Oi8vZGlkOndlYjpwdWJsaXNoZXIuZXhhbXBsZS5jb20vY29tLmVtZGFzaGNtcy5leHBlcmltZW50YWwucGFja2FnZS5wcm9maWxlL2dhbGxlcnlkbmFtZWdHYWxsZXJ5ZHR5cGVtZW1kYXNoLXBsdWdpbmUkdHlwZXgqY29tLmVtZGFzaGNtcy5leHBlcmltZW50YWwucGFja2FnZS5wcm9maWxlZ2F1dGhvcnOBoWRuYW1lcUV4YW1wbGUgUHVibGlzaGVyZ2xpY2Vuc2VjTUlUaHNlY3VyaXR5gaFlZW1haWx0c2VjdXJpdHlAZXhhbXBsZS5jb21qZXh0ZW5zaW9uc6F4M2NvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucHJvZmlsZUV4dGVuc2lvbqNlJHR5cGV4M2NvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucHJvZmlsZUV4dGVuc2lvbmpyZXBvc2l0b3J5eCJodHRwczovL2dpdGh1Yi5jb20vZXhhbXBsZS9nYWxsZXJ5bXJlbGVhc2VQb2xpY3mjZSR0eXBleEFjb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnByb2ZpbGVFeHRlbnNpb24jcmVsZWFzZVBvbGljeWlhcHByb3ZlcnOBcGRpZDpwbGM6YXBwcm92ZXJsY29uZmlybWF0aW9ub2VzY2FsYXRpb24tb25seQ=="; +const ESCALATION_ONLY_REPOSITORY_BEFORE = + "OqJlcm9vdHOB2CpYJQABcRIglLIUbT31g5XsHc7w/77LapA5I+nL7R/L/38bBZhYteJndmVyc2lvbgHdAQFxEiCUshRtPfWDlewdzvD/vstqkDkj6cvtH8v/fxsFmFi14qZjZGlkeB1kaWQ6d2ViOnB1Ymxpc2hlci5leGFtcGxlLmNvbWNyZXZtM211anN2aDUyaXMyNmNzaWdYQLWfg6nhZ4cY/qEnZNROqoyF3HAo+EcgPRuqX8Lbw3BEbizYrFY3TgX9a0f7H48jf2lG+ZQb4AP1ngYbZ7+0CoNkZGF0YdgqWCUAAXESINgPCZKxUNKDLK5vvWIcReUHECWQjKJ/aj9ydxPShTOeZHByZXb2Z3ZlcnNpb24D3gEBcRIg2A8JkrFQ0oMsrm+9YhxF5QcQJZCMon9qP3J3E9KFM56iYWWCpGFrWDJjb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnByb2ZpbGUvZ2FsbGVyeWFwAGF09mF22CpYJQABcRIgw0TlSwQE8Qi5DmeWDUCV1/wovIGUh847dRDPTil7SySkYWtVcmVsZWFzZS9nYWxsZXJ5OjEuMC4wYXAYI2F09mF22CpYJQABcRIgKe3xvLMXzqP1GXCCI8xRmbB0J8uCPTbjue4z0kmJDqphbPbhBAFxEiDDROVLBATxCLkOZ5YNQJXX/Ci8gZSHzjt1EM9OKXtLJKhiaWR4VWF0Oi8vZGlkOndlYjpwdWJsaXNoZXIuZXhhbXBsZS5jb20vY29tLmVtZGFzaGNtcy5leHBlcmltZW50YWwucGFja2FnZS5wcm9maWxlL2dhbGxlcnlkbmFtZWdHYWxsZXJ5ZHR5cGVtZW1kYXNoLXBsdWdpbmUkdHlwZXgqY29tLmVtZGFzaGNtcy5leHBlcmltZW50YWwucGFja2FnZS5wcm9maWxlZ2F1dGhvcnOBoWRuYW1lcUV4YW1wbGUgUHVibGlzaGVyZ2xpY2Vuc2VjTUlUaHNlY3VyaXR5gaFlZW1haWx0c2VjdXJpdHlAZXhhbXBsZS5jb21qZXh0ZW5zaW9uc6F4M2NvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucHJvZmlsZUV4dGVuc2lvbqNlJHR5cGV4M2NvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucHJvZmlsZUV4dGVuc2lvbmpyZXBvc2l0b3J5eCJodHRwczovL2dpdGh1Yi5jb20vZXhhbXBsZS9nYWxsZXJ5bXJlbGVhc2VQb2xpY3mjZSR0eXBleEFjb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnByb2ZpbGVFeHRlbnNpb24jcmVsZWFzZVBvbGljeWlhcHByb3ZlcnOBcGRpZDpwbGM6YXBwcm92ZXJsY29uZmlybWF0aW9ub2VzY2FsYXRpb24tb25seYYDAXESICnt8byzF86j9RlwgiPMUZmwdCfLgj0247nuM9JJiQ6qpWUkdHlwZXgqY29tLmVtZGFzaGNtcy5leHBlcmltZW50YWwucGFja2FnZS5yZWxlYXNlZ3BhY2thZ2VnZ2FsbGVyeWd2ZXJzaW9uZTEuMC4waWFydGlmYWN0c6FncGFja2FnZaNjdXJseCVodHRwczovL2V4YW1wbGUuY29tL2dhbGxlcnktMS4wLjAudGd6aGNoZWNrc3VtbGJjaXFiYXNlbGluZWtjb250ZW50VHlwZXBhcHBsaWNhdGlvbi9nemlwamV4dGVuc2lvbnOheDNjb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnJlbGVhc2VFeHRlbnNpb26iZSR0eXBleDNjb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnJlbGVhc2VFeHRlbnNpb25uZGVjbGFyZWRBY2Nlc3OhZ25ldHdvcmuhZ3JlcXVlc3Sg"; +const ESCALATION_ONLY_REPOSITORY_AFTER = + "OqJlcm9vdHOB2CpYJQABcRIgd66Z6kv1ZzpaZY5x3LqvpVHzyI0GPDWzXRja2p07UDFndmVyc2lvbgHdAQFxEiB3rpnqS/VnOlpljnHcuq+lUfPIjQY8NbNdGNranTtQMaZjZGlkeB1kaWQ6d2ViOnB1Ymxpc2hlci5leGFtcGxlLmNvbWNyZXZtM211anN2aDU1Z2syNmNzaWdYQAagH6XoDxXLhINDqkLnI5YKAP58z9Y1NXfO6FmmhpEVTEaai3sa/VGvfsz09cRnzbzJ2dKTE+znz9fY76wM1K5kZGF0YdgqWCUAAXESICbKK5XxJf/D8rimLj48VYnG8jT9R2LzJiMC7arQnlkNZHByZXb2Z3ZlcnNpb24DlwIBcRIgJsorlfEl/8PyuKYuPjxVicbyNP1HYvMmIwLtqtCeWQ2iYWWDpGFrWDJjb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnByb2ZpbGUvZ2FsbGVyeWFwAGF09mF22CpYJQABcRIgw0TlSwQE8Qi5DmeWDUCV1/wovIGUh847dRDPTil7SySkYWtVcmVsZWFzZS9nYWxsZXJ5OjEuMC4wYXAYI2F09mF22CpYJQABcRIgKe3xvLMXzqP1GXCCI8xRmbB0J8uCPTbjue4z0kmJDqqkYWtDMS4wYXAYNWF09mF22CpYJQABcRIgoo1VqGByjoff0+Pg2xv8KnHaOMEMEbwJl8qTpWpejWNhbPbhBAFxEiDDROVLBATxCLkOZ5YNQJXX/Ci8gZSHzjt1EM9OKXtLJKhiaWR4VWF0Oi8vZGlkOndlYjpwdWJsaXNoZXIuZXhhbXBsZS5jb20vY29tLmVtZGFzaGNtcy5leHBlcmltZW50YWwucGFja2FnZS5wcm9maWxlL2dhbGxlcnlkbmFtZWdHYWxsZXJ5ZHR5cGVtZW1kYXNoLXBsdWdpbmUkdHlwZXgqY29tLmVtZGFzaGNtcy5leHBlcmltZW50YWwucGFja2FnZS5wcm9maWxlZ2F1dGhvcnOBoWRuYW1lcUV4YW1wbGUgUHVibGlzaGVyZ2xpY2Vuc2VjTUlUaHNlY3VyaXR5gaFlZW1haWx0c2VjdXJpdHlAZXhhbXBsZS5jb21qZXh0ZW5zaW9uc6F4M2NvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucHJvZmlsZUV4dGVuc2lvbqNlJHR5cGV4M2NvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucHJvZmlsZUV4dGVuc2lvbmpyZXBvc2l0b3J5eCJodHRwczovL2dpdGh1Yi5jb20vZXhhbXBsZS9nYWxsZXJ5bXJlbGVhc2VQb2xpY3mjZSR0eXBleEFjb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnByb2ZpbGVFeHRlbnNpb24jcmVsZWFzZVBvbGljeWlhcHByb3ZlcnOBcGRpZDpwbGM6YXBwcm92ZXJsY29uZmlybWF0aW9ub2VzY2FsYXRpb24tb25seYYDAXESICnt8byzF86j9RlwgiPMUZmwdCfLgj0247nuM9JJiQ6qpWUkdHlwZXgqY29tLmVtZGFzaGNtcy5leHBlcmltZW50YWwucGFja2FnZS5yZWxlYXNlZ3BhY2thZ2VnZ2FsbGVyeWd2ZXJzaW9uZTEuMC4waWFydGlmYWN0c6FncGFja2FnZaNjdXJseCVodHRwczovL2V4YW1wbGUuY29tL2dhbGxlcnktMS4wLjAudGd6aGNoZWNrc3VtbGJjaXFiYXNlbGluZWtjb250ZW50VHlwZXBhcHBsaWNhdGlvbi9nemlwamV4dGVuc2lvbnOheDNjb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnJlbGVhc2VFeHRlbnNpb26iZSR0eXBleDNjb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnJlbGVhc2VFeHRlbnNpb25uZGVjbGFyZWRBY2Nlc3OhZ25ldHdvcmuhZ3JlcXVlc3Sg9AIBcRIgoo1VqGByjoff0+Pg2xv8KnHaOMEMEbwJl8qTpWpejWOlZSR0eXBleCpjb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnJlbGVhc2VncGFja2FnZWdnYWxsZXJ5Z3ZlcnNpb25lMS4xLjBpYXJ0aWZhY3RzoWdwYWNrYWdlo2N1cmx4JWh0dHBzOi8vZXhhbXBsZS5jb20vZ2FsbGVyeS0xLjEuMC50Z3poY2hlY2tzdW1sYmNpcWJhc2VsaW5la2NvbnRlbnRUeXBlcGFwcGxpY2F0aW9uL2d6aXBqZXh0ZW5zaW9uc6F4M2NvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucmVsZWFzZUV4dGVuc2lvbqJlJHR5cGV4M2NvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucmVsZWFzZUV4dGVuc2lvbm5kZWNsYXJlZEFjY2Vzc6A="; +const ESCALATION_ONLY_SIGNING_KEY = "zDnaeTBBqgbt5fZ557KrELsN77jxpGavqjxcC9yQFSbTf1dc5"; const PROVENANCE = { predicateType: "https://slsa.dev/provenance/v1", url: "https://github.com/example/gallery/attestation.sigstore.json", @@ -230,17 +238,6 @@ function releaseRecord() { const NETWORK_ACCESS = { network: { request: {} } } as const; -function baselineRelease(version: string, declaredAccess: Record) { - const value = releaseRecord(); - value.version = version; - value.extensions[NSID.packageReleaseExtension]!.declaredAccess = declaredAccess; - return { - uri: `at://${PUBLISHER_DID}/${NSID.packageRelease}/gallery:${version}`, - cid: `bafygallery${version.replaceAll(".", "")}`, - value, - }; -} - async function fullReleaseRecord() { const release = releaseRecord(); const icon = pngBytes(128, 128); @@ -294,7 +291,7 @@ function proofBytes(value: string): Uint8Array { interface WorkflowNetworkOptions { artifactSources?: ReadonlyMap; profileProof?: string; - listedReleases?: () => readonly AuthoritativeRecord[]; + repositoryProof?: () => Uint8Array; authoritativeProof?: () => Uint8Array | null; signingKey?: () => string; onArtifactFetch?: () => Response | void | Promise; @@ -360,6 +357,11 @@ function workflowNetwork(options: WorkflowNetworkOptions = {}) { { headers: { "content-type": "application/vnd.ipld.car" } }, ); } + if (url.origin === pdsUrl && url.pathname === "/xrpc/com.atproto.sync.getRepo") { + return new Response(options.repositoryProof?.() ?? proofBytes(profileProof), { + headers: { "content-type": "application/vnd.ipld.car" }, + }); + } if (url.origin === oauthPdsUrl && url.pathname === "/.well-known/oauth-protected-resource") { return Response.json({ resource: oauthPdsUrl, @@ -419,9 +421,6 @@ function workflowNetwork(options: WorkflowNetworkOptions = {}) { : Response.json({ error: "RecordNotFound" }, { status: 400 }); } } - if (url.origin === pdsUrl && url.pathname === "/xrpc/com.atproto.repo.listRecords") { - return Response.json({ records: options.listedReleases?.() ?? [] }); - } if (url.origin === oauthPdsUrl && url.pathname === "/xrpc/com.atproto.repo.createRecord") { const request = input instanceof Request ? input : new Request(url, init); if (options.onCreateRecord) { @@ -1078,12 +1077,13 @@ describe("ReleaseIntentWorkflow", () => { vi.stubGlobal( "fetch", workflowNetwork({ - listedReleases: () => { + repositoryProof: () => { snapshotReads += 1; - return snapshotReads < 4 - ? [] - : [{ uri: CREATED_URI, cid: CREATED_CID, value: releaseRecord() }]; + return proofBytes( + snapshotReads < 4 ? WORKFLOW_REPOSITORY_ABSENT : WORKFLOW_REPOSITORY_PRESENT, + ); }, + signingKey: () => WORKFLOW_REPOSITORY_SIGNING_KEY, onCreateRecord: () => { createAttempts += 1; return Response.json({ uri: CREATED_URI, cid: CREATED_CID }); @@ -1117,11 +1117,11 @@ describe("ReleaseIntentWorkflow", () => { vi.stubGlobal( "fetch", workflowNetwork({ - listedReleases: () => { + repositoryProof: () => { snapshotReads += 1; - return snapshotReads < 4 - ? [] - : [{ uri: "not-an-at-uri", cid: CREATED_CID, value: releaseRecord() }]; + const proof = proofBytes(PROFILE_PROOF); + if (snapshotReads >= 4) proof[proof.length - 1] = (proof.at(-1) ?? 0) ^ 0xff; + return proof; }, }), ); @@ -1147,7 +1147,6 @@ describe("ReleaseIntentWorkflow", () => { }, 15_000); it("does not reintroduce access after a capability-removing release changes the baseline", async () => { - let baseline = baselineRelease("1.0.0", NETWORK_ACCESS); let createAttempts = 0; let removalPublished = false; const release = releaseRecord(); @@ -1159,9 +1158,11 @@ describe("ReleaseIntentWorkflow", () => { workflowNetwork({ profileProof: ESCALATION_ONLY_PROFILE_PROOF, signingKey: () => ESCALATION_ONLY_SIGNING_KEY, - listedReleases: () => [baseline], + repositoryProof: () => + proofBytes( + removalPublished ? ESCALATION_ONLY_REPOSITORY_AFTER : ESCALATION_ONLY_REPOSITORY_BEFORE, + ), onAuthorizationMetadata: () => { - baseline = baselineRelease("1.1.0", {}); removalPublished = true; }, onCreateRecord: () => { @@ -1189,7 +1190,7 @@ describe("ReleaseIntentWorkflow", () => { ); expect(JSON.parse(decision?.resultJson ?? "null")).toMatchObject({ requiresApproval: false, - approvalEvidence: { baselineReleaseCid: "bafygallery100" }, + approvalEvidence: { baselineReleaseCid: expect.stringMatching(/^b/) }, }); expect( (await publisher.listIntentTransitions(PUBLISHER_DID, INTENT_ID)).map( diff --git a/apps/release-service/test/verification-pds.test.ts b/apps/release-service/test/verification-pds.test.ts index bb7b48bbe6..2a994809d6 100644 --- a/apps/release-service/test/verification-pds.test.ts +++ b/apps/release-service/test/verification-pds.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { findAuthoritativeRelease, + findProofVerifiedRelease, PublisherSnapshotError, readPublisherVerificationSnapshot, samePdsOrigin, @@ -12,7 +13,11 @@ import { const PUBLISHER_DID = "did:plc:publisher"; const PROFILE_PROOF = - "OqJlcm9vdHOB2CpYJQABcRIgF0Ia8m10fS1OqIIOB4JeFOEW4V9LnmQWXBI/94ygwf5ndmVyc2lvbgHQAQFxEiAXQhrybXR9LU6ogg4Hgl4U4RbhX0ueZBZcEj/3jKDB/qZjZGlkcWRpZDpwbGM6cHVibGlzaGVyY3Jldm0zbXU1cHVrNmI2MjJsY3NpZ1hAfPdGrehe1wc5/9jLZsz7MEZJI91lsiZC0tGL4jhbekMBbNmWL5c4OgOzG3bN4A32fSgWlBJfmBCTIQGAOjxC6WRkYXRh2CpYJQABcRIgImvcP6xplLZZ8rVKPwE2OLRyr8gA22oR4aV7g5ghQVRkcHJldvZndmVyc2lvbgPeAQFxEiAia9w/rGmUtlnytUo/ATY4tHKvyADbahHhpXuDmCFBVKJhZYKkYWtYMmNvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucHJvZmlsZS9nYWxsZXJ5YXAAYXT2YXbYKlglAAFxEiC+MQX3czvJZMmZLZQZ/si/I2qGq7MUqt/eyLEtrxB416Rha1VyZWxlYXNlL2dhbGxlcnk6MS4wLjBhcBgjYXT2YXbYKlglAAFxEiBWzwMNfBEYUPmMFjzpw+7caiK0pxFOsjDG+NB4L4vb/mFs9s0CAXESIL4xBfdzO8lkyZktlBn+yL8jaoarsxSq397IsS2vEHjXqGJpZHhJYXQ6Ly9kaWQ6cGxjOnB1Ymxpc2hlci9jb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnByb2ZpbGUvZ2FsbGVyeWRzbHVnZ2dhbGxlcnlkdHlwZW1lbWRhc2gtcGx1Z2luZSR0eXBleCpjb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnByb2ZpbGVnYXV0aG9yc4GhZG5hbWV1cHVibGlzaGVyLmV4YW1wbGUuY29tZ2xpY2Vuc2VjTUlUaHNlY3VyaXR5gaFlZW1haWx4HnNlY3VyaXR5QHB1Ymxpc2hlci5leGFtcGxlLmNvbWtsYXN0VXBkYXRlZHgYMjAyNi0wOC0yOFQxNTo1Mzo0My44ODVa"; + "OqJlcm9vdHOB2CpYJQABcRIguIOtOxeeD6PfhhwV1Tbcy0g1a5TRE+tSQA0QlhEj6FRndmVyc2lvbgHQAQFxEiC4g607F54Po9+GHBXVNtzLSDVrlNET61JADRCWESPoVKZjZGlkcWRpZDpwbGM6cHVibGlzaGVyY3Jldm0zbXVqa3M1bG53azI0Y3NpZ1hA4lFxxn7YC9lg4/mEb9l7Lb+uN+8EzZvH6XsUrpCbtNg+kr0+VIQArQba1jZajQL4pc1IeP6Oq1KRWPcVGKZpTGRkYXRh2CpYJQABcRIg5rQ4qhRh79SdMF1zLkkklmnQjgkMGK7mrU2HiQJnRYtkcHJldvZndmVyc2lvbgOXAgFxEiDmtDiqFGHv1J0wXXMuSSSWadCOCQwYruatTYeJAmdFi6JhZYOkYWtYMmNvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucHJvZmlsZS9nYWxsZXJ5YXAAYXT2YXbYKlglAAFxEiCbCJ4mzguVrq3pAScroVTnqCHzCv4UparTIJIiZW7sXqRha1VyZWxlYXNlL2dhbGxlcnk6MS4wLjBhcBgjYXT2YXbYKlglAAFxEiAVgbNAcHSSrRFFo3roii2+pXMBVGSC2AOYbrJfAzWLwqRha0M3LjBhcBg1YXT2YXbYKlglAAFxEiBhFDeoEsxJobozp3Y26kHUHywaIc1posb8QrJvJtD0DWFs9roEAXESIJsInibOC5WurekBJyuhVOeoIfMK/hSlqtMgkiJlbuxep2JpZHhJYXQ6Ly9kaWQ6cGxjOnB1Ymxpc2hlci9jb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnByb2ZpbGUvZ2FsbGVyeWR0eXBlbWVtZGFzaC1wbHVnaW5lJHR5cGV4KmNvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucHJvZmlsZWdhdXRob3JzgaFkbmFtZWlQdWJsaXNoZXJnbGljZW5zZWNNSVRoc2VjdXJpdHmBoWVlbWFpbHRzZWN1cml0eUBleGFtcGxlLmNvbWpleHRlbnNpb25zoXgzY29tLmVtZGFzaGNtcy5leHBlcmltZW50YWwucGFja2FnZS5wcm9maWxlRXh0ZW5zaW9uo2UkdHlwZXgzY29tLmVtZGFzaGNtcy5leHBlcmltZW50YWwucGFja2FnZS5wcm9maWxlRXh0ZW5zaW9uanJlcG9zaXRvcnl4JWh0dHBzOi8vZ2l0aHViLmNvbS9lbWRhc2gtY21zL2dhbGxlcnltcmVsZWFzZVBvbGljeaNlJHR5cGV4QWNvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucHJvZmlsZUV4dGVuc2lvbiNyZWxlYXNlUG9saWN5aWFwcHJvdmVyc4FwZGlkOnBsYzphcHByb3Zlcmxjb25maXJtYXRpb25mYWx3YXlz"; +const REPOSITORY_PROOF = + "OqJlcm9vdHOB2CpYJQABcRIguIOtOxeeD6PfhhwV1Tbcy0g1a5TRE+tSQA0QlhEj6FRndmVyc2lvbgHQAQFxEiC4g607F54Po9+GHBXVNtzLSDVrlNET61JADRCWESPoVKZjZGlkcWRpZDpwbGM6cHVibGlzaGVyY3Jldm0zbXVqa3M1bG53azI0Y3NpZ1hA4lFxxn7YC9lg4/mEb9l7Lb+uN+8EzZvH6XsUrpCbtNg+kr0+VIQArQba1jZajQL4pc1IeP6Oq1KRWPcVGKZpTGRkYXRh2CpYJQABcRIg5rQ4qhRh79SdMF1zLkkklmnQjgkMGK7mrU2HiQJnRYtkcHJldvZndmVyc2lvbgOXAgFxEiDmtDiqFGHv1J0wXXMuSSSWadCOCQwYruatTYeJAmdFi6JhZYOkYWtYMmNvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucHJvZmlsZS9nYWxsZXJ5YXAAYXT2YXbYKlglAAFxEiCbCJ4mzguVrq3pAScroVTnqCHzCv4UparTIJIiZW7sXqRha1VyZWxlYXNlL2dhbGxlcnk6MS4wLjBhcBgjYXT2YXbYKlglAAFxEiAVgbNAcHSSrRFFo3roii2+pXMBVGSC2AOYbrJfAzWLwqRha0M3LjBhcBg1YXT2YXbYKlglAAFxEiBhFDeoEsxJobozp3Y26kHUHywaIc1posb8QrJvJtD0DWFs9roEAXESIJsInibOC5WurekBJyuhVOeoIfMK/hSlqtMgkiJlbuxep2JpZHhJYXQ6Ly9kaWQ6cGxjOnB1Ymxpc2hlci9jb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnByb2ZpbGUvZ2FsbGVyeWR0eXBlbWVtZGFzaC1wbHVnaW5lJHR5cGV4KmNvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucHJvZmlsZWdhdXRob3JzgaFkbmFtZWlQdWJsaXNoZXJnbGljZW5zZWNNSVRoc2VjdXJpdHmBoWVlbWFpbHRzZWN1cml0eUBleGFtcGxlLmNvbWpleHRlbnNpb25zoXgzY29tLmVtZGFzaGNtcy5leHBlcmltZW50YWwucGFja2FnZS5wcm9maWxlRXh0ZW5zaW9uo2UkdHlwZXgzY29tLmVtZGFzaGNtcy5leHBlcmltZW50YWwucGFja2FnZS5wcm9maWxlRXh0ZW5zaW9uanJlcG9zaXRvcnl4JWh0dHBzOi8vZ2l0aHViLmNvbS9lbWRhc2gtY21zL2dhbGxlcnltcmVsZWFzZVBvbGljeaNlJHR5cGV4QWNvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucHJvZmlsZUV4dGVuc2lvbiNyZWxlYXNlUG9saWN5aWFwcHJvdmVyc4FwZGlkOnBsYzphcHByb3Zlcmxjb25maXJtYXRpb25mYWx3YXlz9AIBcRIgFYGzQHB0kq0RRaN66IotvqVzAVRkgtgDmG6yXwM1i8KlZSR0eXBleCpjb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnJlbGVhc2VncGFja2FnZWdnYWxsZXJ5Z3ZlcnNpb25lMS4wLjBpYXJ0aWZhY3RzoWdwYWNrYWdlo2N1cmx4JWh0dHBzOi8vZXhhbXBsZS5jb20vZ2FsbGVyeS0xLjAuMC50Z3poY2hlY2tzdW1sYmNpcWJhc2VsaW5la2NvbnRlbnRUeXBlcGFwcGxpY2F0aW9uL2d6aXBqZXh0ZW5zaW9uc6F4M2NvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucmVsZWFzZUV4dGVuc2lvbqJlJHR5cGV4M2NvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucmVsZWFzZUV4dGVuc2lvbm5kZWNsYXJlZEFjY2Vzc6D0AgFxEiBhFDeoEsxJobozp3Y26kHUHywaIc1posb8QrJvJtD0DaVlJHR5cGV4KmNvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucmVsZWFzZWdwYWNrYWdlZ2dhbGxlcnlndmVyc2lvbmUxLjcuMGlhcnRpZmFjdHOhZ3BhY2thZ2WjY3VybHglaHR0cHM6Ly9leGFtcGxlLmNvbS9nYWxsZXJ5LTEuNy4wLnRnemhjaGVja3N1bWxiY2lxYmFzZWxpbmVrY29udGVudFR5cGVwYXBwbGljYXRpb24vZ3ppcGpleHRlbnNpb25zoXgzY29tLmVtZGFzaGNtcy5leHBlcmltZW50YWwucGFja2FnZS5yZWxlYXNlRXh0ZW5zaW9uomUkdHlwZXgzY29tLmVtZGFzaGNtcy5leHBlcmltZW50YWwucGFja2FnZS5yZWxlYXNlRXh0ZW5zaW9ubmRlY2xhcmVkQWNjZXNzoA=="; +const REPOSITORY_PROOF_WITH_PROPOSED = + "OqJlcm9vdHOB2CpYJQABcRIg8sm4dQ9lByY0xr1kL6Hz48iIe3d/hw0lf4YPySo/r0dndmVyc2lvbgHQAQFxEiDyybh1D2UHJjTGvWQvofPjyIh7d3+HDSV/hg/JKj+vR6ZjZGlkcWRpZDpwbGM6cHVibGlzaGVyY3Jldm0zbXVqa3M1bHpuazI0Y3NpZ1hAqvylIr2sgAbW1YV1lZx5mgzHMoHuezih4wfgUmZXQd4cjK/tgZd0k2Q7L07vOjDkgA7kZzlAH0xY3ysgwzyOqGRkYXRh2CpYJQABcRIgUvcaAAneyG9fVRU7P+iIJb3CImXjuRhE0PZzpiX1IpdkcHJldvZndmVyc2lvbgPSAgFxEiBS9xoACd7Ib19VFTs/6IglvcIiZeO5GETQ9nOmJfUil6JhZYSkYWtYMmNvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucHJvZmlsZS9nYWxsZXJ5YXAAYXT2YXbYKlglAAFxEiCbCJ4mzguVrq3pAScroVTnqCHzCv4UparTIJIiZW7sXqRha1VyZWxlYXNlL2dhbGxlcnk6MS4wLjBhcBgjYXT2YXbYKlglAAFxEiAVgbNAcHSSrRFFo3roii2+pXMBVGSC2AOYbrJfAzWLwqRha0M3LjBhcBg1YXT2YXbYKlglAAFxEiBhFDeoEsxJobozp3Y26kHUHywaIc1posb8QrJvJtD0DaRha0UyLjAuMGFwGDNhdPZhdtgqWCUAAXESIC6WNHToQDAXjf7Q4VgGmVl1AQkDEDZxj8LbF1g+SMPsYWz2ugQBcRIgmwieJs4Lla6t6QEnK6FU56gh8wr+FKWq0yCSImVu7F6nYmlkeElhdDovL2RpZDpwbGM6cHVibGlzaGVyL2NvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucHJvZmlsZS9nYWxsZXJ5ZHR5cGVtZW1kYXNoLXBsdWdpbmUkdHlwZXgqY29tLmVtZGFzaGNtcy5leHBlcmltZW50YWwucGFja2FnZS5wcm9maWxlZ2F1dGhvcnOBoWRuYW1laVB1Ymxpc2hlcmdsaWNlbnNlY01JVGhzZWN1cml0eYGhZWVtYWlsdHNlY3VyaXR5QGV4YW1wbGUuY29tamV4dGVuc2lvbnOheDNjb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnByb2ZpbGVFeHRlbnNpb26jZSR0eXBleDNjb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnByb2ZpbGVFeHRlbnNpb25qcmVwb3NpdG9yeXglaHR0cHM6Ly9naXRodWIuY29tL2VtZGFzaC1jbXMvZ2FsbGVyeW1yZWxlYXNlUG9saWN5o2UkdHlwZXhBY29tLmVtZGFzaGNtcy5leHBlcmltZW50YWwucGFja2FnZS5wcm9maWxlRXh0ZW5zaW9uI3JlbGVhc2VQb2xpY3lpYXBwcm92ZXJzgXBkaWQ6cGxjOmFwcHJvdmVybGNvbmZpcm1hdGlvbmZhbHdheXP0AgFxEiAVgbNAcHSSrRFFo3roii2+pXMBVGSC2AOYbrJfAzWLwqVlJHR5cGV4KmNvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucmVsZWFzZWdwYWNrYWdlZ2dhbGxlcnlndmVyc2lvbmUxLjAuMGlhcnRpZmFjdHOhZ3BhY2thZ2WjY3VybHglaHR0cHM6Ly9leGFtcGxlLmNvbS9nYWxsZXJ5LTEuMC4wLnRnemhjaGVja3N1bWxiY2lxYmFzZWxpbmVrY29udGVudFR5cGVwYXBwbGljYXRpb24vZ3ppcGpleHRlbnNpb25zoXgzY29tLmVtZGFzaGNtcy5leHBlcmltZW50YWwucGFja2FnZS5yZWxlYXNlRXh0ZW5zaW9uomUkdHlwZXgzY29tLmVtZGFzaGNtcy5leHBlcmltZW50YWwucGFja2FnZS5yZWxlYXNlRXh0ZW5zaW9ubmRlY2xhcmVkQWNjZXNzoPQCAXESIGEUN6gSzEmhujOndjbqQdQfLBohzWmixvxCsm8m0PQNpWUkdHlwZXgqY29tLmVtZGFzaGNtcy5leHBlcmltZW50YWwucGFja2FnZS5yZWxlYXNlZ3BhY2thZ2VnZ2FsbGVyeWd2ZXJzaW9uZTEuNy4waWFydGlmYWN0c6FncGFja2FnZaNjdXJseCVodHRwczovL2V4YW1wbGUuY29tL2dhbGxlcnktMS43LjAudGd6aGNoZWNrc3VtbGJjaXFiYXNlbGluZWtjb250ZW50VHlwZXBhcHBsaWNhdGlvbi9nemlwamV4dGVuc2lvbnOheDNjb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnJlbGVhc2VFeHRlbnNpb26iZSR0eXBleDNjb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnJlbGVhc2VFeHRlbnNpb25uZGVjbGFyZWRBY2Nlc3Og9AIBcRIgLpY0dOhAMBeN/tDhWAaZWXUBCQMQNnGPwtsXWD5Iw+ylZSR0eXBleCpjb20uZW1kYXNoY21zLmV4cGVyaW1lbnRhbC5wYWNrYWdlLnJlbGVhc2VncGFja2FnZWdnYWxsZXJ5Z3ZlcnNpb25lMi4wLjBpYXJ0aWZhY3RzoWdwYWNrYWdlo2N1cmx4JWh0dHBzOi8vZXhhbXBsZS5jb20vZ2FsbGVyeS0yLjAuMC50Z3poY2hlY2tzdW1sYmNpcXByb3Bvc2Vka2NvbnRlbnRUeXBlcGFwcGxpY2F0aW9uL2d6aXBqZXh0ZW5zaW9uc6F4M2NvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucmVsZWFzZUV4dGVuc2lvbqJlJHR5cGV4M2NvbS5lbWRhc2hjbXMuZXhwZXJpbWVudGFsLnBhY2thZ2UucmVsZWFzZUV4dGVuc2lvbm5kZWNsYXJlZEFjY2Vzc6A="; function resolver(): ActorResolver { return { @@ -35,7 +40,7 @@ function proofResolver(): DirectPdsDidDocumentResolver { id: `${PUBLISHER_DID}#atproto`, type: "Multikey", controller: PUBLISHER_DID, - publicKeyMultibase: "zDnaeXJ3AAAYhS8fq5tKwBusKZeBoQQeaZyUE2KepuqMa6FuF", + publicKeyMultibase: "zDnaejExR13CZ7p99ojitvboj6ZaYzxhMDqJwnZd7APbohKkR", }, ], service: [ @@ -55,6 +60,12 @@ function profileProofResponse(tampered = false): Response { return new Response(bytes, { headers: { "content-type": "application/vnd.ipld.car" } }); } +function repositoryProofResponse(encoded = REPOSITORY_PROOF, tampered = false): Response { + const bytes = Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0)); + if (tampered) bytes[bytes.length - 1] = (bytes.at(-1) ?? 0) ^ 0xff; + return new Response(bytes, { headers: { "content-type": "application/vnd.ipld.car" } }); +} + function release(version: string, packageSlug = "gallery") { return { uri: `at://${PUBLISHER_DID}/${NSID.packageRelease}/${packageSlug}:${version}`, @@ -64,7 +75,13 @@ function release(version: string, packageSlug = "gallery") { } function snapshotFetch( - options: { privateAddress?: boolean; proposedExists?: boolean; tamperedProfile?: boolean } = {}, + options: { + privateAddress?: boolean; + proposedExists?: boolean; + repositoryContentLength?: number; + repositoryNotFound?: boolean; + tamperedProfile?: boolean; + } = {}, ) { return async (input: RequestInfo | URL): Promise => { const url = new URL(input instanceof Request ? input.url : input.toString()); @@ -80,6 +97,19 @@ function snapshotFetch( if (url.pathname === "/xrpc/com.atproto.sync.getRecord") { return profileProofResponse(options.tamperedProfile); } + if (url.pathname === "/xrpc/com.atproto.sync.getRepo") { + if (options.repositoryNotFound) { + return Response.json({ error: "RepoNotFound" }, { status: 404 }); + } + const response = repositoryProofResponse( + options.proposedExists ? REPOSITORY_PROOF_WITH_PROPOSED : REPOSITORY_PROOF, + options.tamperedProfile, + ); + if (options.repositoryContentLength === undefined) return response; + const headers = new Headers(response.headers); + headers.set("content-length", String(options.repositoryContentLength)); + return new Response(response.body, { headers }); + } if (url.pathname === "/xrpc/com.atproto.repo.listRecords") { expect(url.searchParams.has("rkeyStart")).toBe(false); expect(url.searchParams.has("rkeyEnd")).toBe(false); @@ -148,6 +178,9 @@ describe("publisher verification snapshot", () => { if (url.pathname === "/xrpc/com.atproto.sync.getRecord") { return profileProofResponse(); } + if (url.pathname === "/xrpc/com.atproto.sync.getRepo") { + return repositoryProofResponse(); + } if (url.pathname === "/xrpc/com.atproto.repo.listRecords") { return Response.json({ records: [release("1.0.0")] }); } @@ -163,7 +196,7 @@ describe("publisher verification snapshot", () => { expect(snapshot.profile).toMatchObject({ value: { license: "MIT" } }); }); - it("reads the authoritative profile, proves absence, and selects the highest semver baseline", async () => { + it("reads the authoritative profile, proves absence, and selects the highest signed baseline", async () => { await expect( readPublisherVerificationSnapshot(PUBLISHER_DID, "gallery", "2.0.0", { actorResolver: resolver(), @@ -174,8 +207,8 @@ describe("publisher verification snapshot", () => { profile: { cid: expect.stringMatching(/^b/) }, proposedRkey: "gallery:2.0.0", proposedReleaseAbsent: true, - baselineVersion: "2.0.0-rc.1", - baseline: { cid: "bafygallery200-rc1" }, + baselineVersion: "1.7.0", + baseline: { cid: expect.stringMatching(/^b/) }, }); }); @@ -196,7 +229,7 @@ describe("publisher verification snapshot", () => { didDocumentResolver: proofResolver(), fetch: snapshotFetch({ tamperedProfile: true }), }), - ).rejects.toMatchObject({ code: "PROFILE_INVALID" }); + ).rejects.toMatchObject({ code: "RELEASE_LIST_INVALID" }); }); it("rejects private PDS resolution before record egress", async () => { @@ -208,6 +241,70 @@ describe("publisher verification snapshot", () => { }), ).rejects.toBeInstanceOf(PublisherSnapshotError); }); + + it("accepts a repository export above the single-record response budget", async () => { + await expect( + readPublisherVerificationSnapshot(PUBLISHER_DID, "gallery", "2.0.0", { + didDocumentResolver: proofResolver(), + fetch: snapshotFetch({ repositoryContentLength: 600 * 1024 }), + }), + ).resolves.toMatchObject({ baselineVersion: "1.7.0" }); + }); + + it("maps a restored sync.getRepo 404 to an invalid publisher identity", async () => { + await expect( + readPublisherVerificationSnapshot(PUBLISHER_DID, "gallery", "2.0.0", { + didDocumentResolver: proofResolver(), + fetch: snapshotFetch({ repositoryNotFound: true }), + }), + ).rejects.toMatchObject({ code: "PUBLISHER_IDENTITY_INVALID" }); + }); + + it("ignores an unsigned higher-semver baseline injected into listRecords", async () => { + const fetch: typeof globalThis.fetch = async (input, init) => { + const url = new URL(input instanceof Request ? input.url : input.toString()); + if (url.hostname === "cloudflare-dns.com") { + return Response.json({ Status: 0, Answer: [{ type: 1, data: "93.184.216.34" }] }); + } + if (url.pathname === "/xrpc/com.atproto.repo.listRecords") { + return Response.json({ records: [release("99.0.0")] }); + } + if (url.pathname === "/xrpc/com.atproto.sync.getRecord") return profileProofResponse(); + if (url.pathname === "/xrpc/com.atproto.sync.getRepo") return repositoryProofResponse(); + throw new Error(`Unexpected request: ${url.toString()} ${String(init?.method)}`); + }; + + await expect( + readPublisherVerificationSnapshot(PUBLISHER_DID, "gallery", "2.0.0", { + actorResolver: resolver(), + didDocumentResolver: proofResolver(), + fetch, + }), + ).resolves.toMatchObject({ baselineVersion: "1.7.0" }); + }); + + it("retains a genuine baseline omitted from listRecords", async () => { + const fetch: typeof globalThis.fetch = async (input, init) => { + const url = new URL(input instanceof Request ? input.url : input.toString()); + if (url.hostname === "cloudflare-dns.com") { + return Response.json({ Status: 0, Answer: [{ type: 1, data: "93.184.216.34" }] }); + } + if (url.pathname === "/xrpc/com.atproto.repo.listRecords") { + return Response.json({ records: [] }); + } + if (url.pathname === "/xrpc/com.atproto.sync.getRecord") return profileProofResponse(); + if (url.pathname === "/xrpc/com.atproto.sync.getRepo") return repositoryProofResponse(); + throw new Error(`Unexpected request: ${url.toString()} ${String(init?.method)}`); + }; + + await expect( + readPublisherVerificationSnapshot(PUBLISHER_DID, "gallery", "2.0.0", { + actorResolver: resolver(), + didDocumentResolver: proofResolver(), + fetch, + }), + ).resolves.toMatchObject({ baselineVersion: "1.7.0" }); + }); }); describe("authoritative release reconciliation read", () => { @@ -235,4 +332,28 @@ describe("authoritative release reconciliation read", () => { }), ).rejects.toMatchObject({ code: "RELEASE_RECORD_INVALID" }); }); + + it("preserves sync.getRecord 404 status through the guarded fetch", async () => { + const fetch: typeof globalThis.fetch = async (input) => { + const url = new URL(input instanceof Request ? input.url : input.toString()); + if (url.hostname === "cloudflare-dns.com") { + return Response.json({ Status: 0, Answer: [{ type: 1, data: "93.184.216.34" }] }); + } + if (url.pathname === "/xrpc/com.atproto.repo.getRecord") { + return Response.json(release("2.0.0")); + } + if (url.pathname === "/xrpc/com.atproto.sync.getRecord") { + return Response.json({ error: "RecordNotFound" }, { status: 404 }); + } + throw new Error(`Unexpected request: ${url.toString()}`); + }; + + await expect( + findProofVerifiedRelease(PUBLISHER_DID, "gallery", "2.0.0", { + actorResolver: resolver(), + didDocumentResolver: proofResolver(), + fetch, + }), + ).resolves.toBeNull(); + }); }); diff --git a/packages/atproto-test-utils/src/fake-repo.ts b/packages/atproto-test-utils/src/fake-repo.ts index a79b0a7430..c89875f544 100644 --- a/packages/atproto-test-utils/src/fake-repo.ts +++ b/packages/atproto-test-utils/src/fake-repo.ts @@ -18,7 +18,7 @@ import { MemoryBlockstore, Repo, WriteOpAction, - blocksToCarFile, + getFullRepo, getRecords, type RecordCreateOp, type RecordPath, @@ -152,15 +152,14 @@ export class FakeRepo { /** * Returns the full repo as a CAR. Used for `com.atproto.sync.getRepo` and - * by reconciliation flows that want to verify the entire repo at once. - * Not used by the verification path today, but it's a one-liner that - * matches a real PDS endpoint, so we expose it for future tests. + * by verification flows that need a complete repository snapshot. */ async getFullRepoCar(): Promise { const head = await this.storage.getRoot(); if (!head) throw new Error("repo has no root commit"); - const blocks = await this.storage.getBlocks([head]); - return blocksToCarFile(head, blocks.blocks); + const chunks: Uint8Array[] = []; + for await (const chunk of getFullRepo(this.storage, head)) chunks.push(chunk); + return concatBytes(chunks); } /** diff --git a/packages/atproto-test-utils/src/mock-pds.ts b/packages/atproto-test-utils/src/mock-pds.ts index 9b53935ad6..f75fa96364 100644 --- a/packages/atproto-test-utils/src/mock-pds.ts +++ b/packages/atproto-test-utils/src/mock-pds.ts @@ -3,7 +3,7 @@ * requests against them. Implements both the publish-side endpoints * (`com.atproto.repo.applyWrites`, `putRecord`, `getRecord`-as-JSON) and the * aggregator-side endpoints (`com.atproto.sync.getRecord`-as-CAR, - * `com.atproto.repo.listRecords`). + * `com.atproto.sync.getRepo`, `com.atproto.repo.listRecords`). * * Conforms to `@atcute/client`'s `FetchHandlerObject` so it can be plugged in * via `Client.fromHandler({ handler: pds })` for the publish path. The @@ -11,9 +11,9 @@ * so the same handler is reachable via a wrapping `fetch` shim too. * * Response shapes mirror the cirrus PDS reference implementation: CAR bytes - * for `sync.getRecord` with `Content-Type: application/vnd.ipld.car`, JSON - * `{ records, cursor? }` for `listRecords`, JSON `{ uri, cid, value }` for - * `repo.getRecord`. + * for `sync.getRecord` and `sync.getRepo` with + * `Content-Type: application/vnd.ipld.car`, JSON `{ records, cursor? }` for + * `listRecords`, and JSON `{ uri, cid, value }` for `repo.getRecord`. */ import type { FetchHandlerObject } from "@atcute/client"; @@ -66,6 +66,8 @@ export class MockPds implements FetchHandlerObject { return this.repoPutRecord(body); case "get /xrpc/com.atproto.sync.getRecord": return this.syncGetRecord(url); + case "get /xrpc/com.atproto.sync.getRepo": + return this.syncGetRepo(url); default: { // Distinguish "wrong method" from "unknown endpoint" — real // PDSes return 405 vs 404, and tests asserting on those status @@ -76,6 +78,7 @@ export class MockPds implements FetchHandlerObject { "/xrpc/com.atproto.repo.applyWrites", "/xrpc/com.atproto.repo.putRecord", "/xrpc/com.atproto.sync.getRecord", + "/xrpc/com.atproto.sync.getRepo", ]); if (knownRoutes.has(url.pathname)) { return jsonResponse(405, { @@ -247,6 +250,28 @@ export class MockPds implements FetchHandlerObject { }); } } + + private async syncGetRepo(url: URL): Promise { + const did = parseDid(url.searchParams.get("did")); + if (!did) return invalidRequest("missing or malformed did"); + const repo = this.repos.get(did); + if (!repo) return notFound("RepoNotFound", `MockPds does not host ${did}`); + try { + const car = await repo.getFullRepoCar(); + return new Response(car, { + status: 200, + headers: { + "Content-Type": "application/vnd.ipld.car", + "Content-Length": car.length.toString(), + }, + }); + } catch (err) { + return jsonResponse(500, { + error: "InternalServerError", + message: err instanceof Error ? err.message : "failed to build repository CAR", + }); + } + } } // ─── helpers ───────────────────────────────────────────────────────────────── diff --git a/packages/registry-client/src/direct-pds/index.ts b/packages/registry-client/src/direct-pds/index.ts index 476d8490bb..6c8dcee282 100644 --- a/packages/registry-client/src/direct-pds/index.ts +++ b/packages/registry-client/src/direct-pds/index.ts @@ -14,7 +14,7 @@ import { import type { AtprotoDid, Did } from "@atcute/lexicons/syntax"; import { isDid } from "@atcute/lexicons/syntax"; import { safeParse } from "@atcute/lexicons/validations"; -import { verifyRecord } from "@atcute/repo"; +import { fromStream, verifyRecord } from "@atcute/repo"; import { NSID, PackageProfile, PackageRelease } from "@emdash-cms/registry-lexicons"; export const DEFAULT_DIRECT_PDS_REQUEST_TIMEOUT_MS = 10_000; @@ -38,6 +38,7 @@ export type DirectPdsReadErrorCode = | "PROFILE_LEXICON_INVALID" | "RECORD_NOT_FOUND" | "RECORD_PROOF_INVALID" + | "REPOSITORY_NOT_FOUND" | "RELEASE_LEXICON_INVALID"; export class DirectPdsReadError extends Error { @@ -80,6 +81,11 @@ export interface DirectPdsReleaseRecord { value: PackageRelease.Main; } +export interface DirectPdsPackageRepository { + profile: DirectPdsProfileRecord; + releases: readonly DirectPdsReleaseRecord[]; +} + interface ResolvedPublisher { pds: URL; publicKey: PublicKey; @@ -156,6 +162,106 @@ export class DirectPdsClient { }; } + async getPackageRepository(packageSlug: string): Promise { + validatePackageSlug(packageSlug); + const publisher = await this.#getResolvedPublisher(); + const url = new URL("/xrpc/com.atproto.sync.getRepo", publisher.pds); + url.searchParams.set("did", this.did); + const response = await this.#fetch(url, { + method: "GET", + headers: { Accept: "application/vnd.ipld.car" }, + }); + if (response.status === 404) { + throw new DirectPdsReadError( + "REPOSITORY_NOT_FOUND", + "The publisher PDS does not contain the requested repository.", + 404, + ); + } + if (!response.ok) { + throw new DirectPdsReadError( + "PDS_REQUEST_FAILED", + `The publisher PDS returned HTTP ${response.status}.`, + response.status, + ); + } + const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim(); + if (contentType !== "application/vnd.ipld.car") { + throw new DirectPdsReadError( + "PDS_RESPONSE_TYPE_INVALID", + "The publisher PDS did not return an AT Protocol repository export.", + ); + } + const carBytes = new Uint8Array(await response.arrayBuffer()); + try { + const verifiedProfile = await verifyRecord({ + did: this.did, + collection: NSID.packageProfile, + rkey: packageSlug, + publicKey: publisher.publicKey, + carBytes, + }); + const parsedProfile = safeParse(PackageProfile.mainSchema, verifiedProfile.record); + if (!parsedProfile.ok) { + throw new DirectPdsReadError( + "PROFILE_LEXICON_INVALID", + "The publisher repository contains a malformed package profile.", + ); + } + const releases: DirectPdsReleaseRecord[] = []; + const stream = new Response(carBytes).body; + if (stream === null) throw new Error("Repository export is empty"); + for await (const entry of fromStream(stream)) { + if (entry.collection !== NSID.packageRelease) continue; + const prefix = `${packageSlug}:`; + if (!entry.rkey.startsWith(prefix)) continue; + const parsedRelease = safeParse(PackageRelease.mainSchema, entry.record); + if ( + !parsedRelease.ok || + parsedRelease.value.package !== packageSlug || + entry.rkey !== `${packageSlug}:${parsedRelease.value.version}` + ) { + throw new DirectPdsReadError( + "RELEASE_LEXICON_INVALID", + "The publisher repository contains a malformed package release.", + ); + } + releases.push({ + uri: `at://${this.did}/${NSID.packageRelease}/${entry.rkey}`, + cid: entry.cid.$link, + rkey: entry.rkey, + value: parsedRelease.value, + }); + } + return { + profile: { + uri: `at://${this.did}/${NSID.packageProfile}/${packageSlug}`, + cid: verifiedProfile.cid, + rkey: packageSlug, + value: parsedProfile.value, + }, + releases, + }; + } catch (error) { + if (error instanceof DirectPdsReadError) throw error; + throw new DirectPdsReadError( + "RECORD_PROOF_INVALID", + "The publisher repository export or commit signature is invalid.", + ); + } + } + + async #getResolvedPublisher(): Promise { + this.#resolvedPublisher ??= this.#resolvePublisher(); + const pendingPublisher = this.#resolvedPublisher; + try { + return await pendingPublisher; + } catch (error) { + if (this.#resolvedPublisher === pendingPublisher) this.#resolvedPublisher = undefined; + throw error; + } + } + async #resolvePublisher(): Promise { let document: DidDocument; try { @@ -211,15 +317,7 @@ export class DirectPdsClient { collection: string, rkey: string, ): Promise<{ cid: string; value: unknown }> { - this.#resolvedPublisher ??= this.#resolvePublisher(); - const pendingPublisher = this.#resolvedPublisher; - let publisher: ResolvedPublisher; - try { - publisher = await pendingPublisher; - } catch (error) { - if (this.#resolvedPublisher === pendingPublisher) this.#resolvedPublisher = undefined; - throw error; - } + const publisher = await this.#getResolvedPublisher(); const url = new URL("/xrpc/com.atproto.sync.getRecord", publisher.pds); url.searchParams.set("did", this.did); url.searchParams.set("collection", collection); diff --git a/packages/registry-client/tests/direct-pds.test.ts b/packages/registry-client/tests/direct-pds.test.ts index 889c43f2b5..b37d26ccdd 100644 --- a/packages/registry-client/tests/direct-pds.test.ts +++ b/packages/registry-client/tests/direct-pds.test.ts @@ -99,6 +99,48 @@ describe("DirectPdsClient", () => { expect(resolve).toHaveBeenCalledOnce(); }); + it("verifies and enumerates a complete package repository export", async () => { + const harness = await createPublisher(); + + await expect(client(harness).getPackageRepository("gallery")).resolves.toMatchObject({ + profile: { + uri: `at://${ALICE_DID}/com.emdashcms.experimental.package.profile/gallery`, + cid: expect.stringMatching(/^b/), + }, + releases: [ + { + uri: `at://${ALICE_DID}/com.emdashcms.experimental.package.release/gallery:1.0.0`, + cid: expect.stringMatching(/^b/), + value: { package: "gallery", version: "1.0.0" }, + }, + ], + }); + expect(harness.fixture.pds.callsTo("com.atproto.sync.getRepo")).toHaveLength(1); + expect(harness.fixture.pds.callsTo("com.atproto.repo.listRecords")).toHaveLength(0); + }); + + it("reports a missing repository export distinctly from other PDS failures", async () => { + const harness = await createPublisher(); + const fetch: typeof globalThis.fetch = () => + Promise.resolve(Response.json({ error: "RepoNotFound" }, { status: 404 })); + + await expect(client(harness, { fetch }).getPackageRepository("gallery")).rejects.toMatchObject({ + code: "REPOSITORY_NOT_FOUND", + status: 404, + }); + }); + + it("preserves record-not-found errors from sync.getRecord", async () => { + const harness = await createPublisher(); + const fetch: typeof globalThis.fetch = () => + Promise.resolve(Response.json({ error: "RecordNotFound" }, { status: 404 })); + + await expect(client(harness, { fetch }).getPackageProfile("gallery")).rejects.toMatchObject({ + code: "RECORD_NOT_FOUND", + status: 404, + }); + }); + it("resolves the publisher DID before fetching its repository proof", async () => { const harness = await createPublisher(); const document = harness.fixture.didResolver.resolve(harness.publisher.did);