Repository navigation
Conversation
8d92052 to
8cae402
Compare
| if (attestationContent != null) | ||
| PackageVersionAsset.init( | ||
| package: key.package, | ||
| version: key.version, | ||
| kind: AssetKind.attestation, | ||
| versionCreated: versionCreated, | ||
| path: '${key.package}-${key.version}.sigstore.json', | ||
| // Note: not capped, a truncated attestation could never be verified. | ||
| // The length is checked when the attestation is uploaded. | ||
| textContent: attestationContent, | ||
| ), |
There was a problem hiding this comment.
Storing the attestation only as a PackageVersionAsset and serving it directly from the database in getPackageVersionAttestation has a few architectural issues:
- Canonical vs. derived storage:
PackageVersionAssetis for derived data extracted from the package tarball (derivePackageVersionEntitiesfor README, CHANGELOG, pubspec, license; seedoc/entities.md). Because the attestation bundle is uploaded alongside the tarball rather than inside it, it is canonical data. If we only store it inPackageVersionAssetand deletetmp/<guid>.attestation.jsonfrom_incomingBucket, it is never stored in_canonicalBucket(packages/<package>-<version>...), and re-runningderivePackageVersionEntitiesfrom the canonical archive would lose the attestation. It should be copied into_canonicalBucketalongside the tarball in_performTarballUpload. - Serving via
ExportedApi/ GCS:GET /api/packages/<package>/versions/<version>/attestationwill be called duringdart pub get(inpub#4875). All otherpub getendpoints (/api/packages/<pkg>,/api/packages/<pkg>/advisories,/api/archives/...) are exported to_exportedApiBucketviaExportedApi(app/lib/package/api_export/exported_api.dart) and served directly from GCS by GCLB (and exempted from Cloud Armor/api/rate limits). Exporting attestations viaExportedApialso meansApiExporter.synchronizePackagewill automatically remove exported attestations if a package or version is moderated (lookupPackageVersionAssetcurrently does not checkisPackageVisible/pv.isNotVisible). - Cloud Armor POST rate limit on upload: In production, Cloud Armor rate-limits non-GET requests to
pub.devto 5 requests per 3 minutes per IP (which wasn't hit bypub publishpreviously because the tarballPOSTgoes tostorage.googleapis.com, whilenewandnewUploadFinishonpub.devareGETrequests with higher per-route limits). SendingPOST /api/packages/versions/newUploadAttestation/<uploadId>topub.devwill hit that limit when CI runners publish >5 packages from a monorepo in 3 minutes, so we'll need to adjust our Cloud Armor rules (or upload to GCS).
There was a problem hiding this comment.
Done for 1. and 2.; 3. needs a decision from you.
1. Canonical storage. The attestation is now copied from the incoming bucket to the canonical bucket inside _performTarballUpload, right after the archive, as packages/<package>-<version>.tar.gz.sigstore.json (the Sigstore convention of naming a bundle after the artifact it attests to). AssetKind.attestation is gone: the attestation is not derived from the archive, so it no longer pretends to be one. removePackageTarball deletes both objects, listVersionsInCanonicalBucket now filters by the .tar.gz suffix (it used to accept anything under packages/<pkg>-), and listAttestationsInCanonicalBucket lists the bundles.
2. Exported API. ExportedPackage.attestation(version) writes <prefix>/api/packages/<package>/versions/<version>/attestation (verbatim bytes, application/json, 2h max-age like the archives, no Content-Disposition), synchronizeAttestations mirrors synchronizeTarballs (they share one implementation now), and GC / delete() cover the new objects. ApiExporter.synchronizePackage syncs attestations after the archives and before writing the version listing — this ordering matters for the client: pub (dart-lang/pub#4875) fetches the attestation when the listing says the version has one and fails the install on a 404, it does not retry. So once slsa_level is set in #9552 the order is part of the contract (there is a note in the spec about it). Moderation is handled like for archives: synchronizePackage deletes exported attestations of versions that are no longer listed, and the app handler (kept as the fallback for hosts that do not route the path to the bucket, like listVersions) now checks package and version visibility and serves the canonical copy.
What I cannot do from here: the GCLB URL map has to route /api/packages/*/versions/*/attestation to the bucket before this is actually served from GCS (and exempt from the /api/ rate limits). Until then the handler serves it, which is also what staging will do. An alternative that avoids the URL-map change would be to export it under /api/archives/<package>-<version>.tar.gz.sigstore.json and advertise the URL in the version listing (attestation_url, next to archive_url). That is a protocol change in the pub spec, so I did not do it, but I am happy to go that way if you prefer it — it would also make life easier for mirrors.
3. Cloud Armor. Agreed that a monorepo publishing >5 packages in 3 minutes would hit the POST limit. Two options: (a) keep the POST and exempt /api/packages/versions/newUploadAttestation/* in the Cloud Armor policy — it is authenticated, ≤128 KB, and bounded by the upload rate limit; or (b) hand out a second signed GCS upload policy like for the archive, which keeps pub.dev out of the upload path but costs an extra iam.signBlob per publish, loses the early JSON check, and changes the client protocol to a multipart POST. I would go with (a) since it is a configuration change and keeps the simpler protocol, but either way it needs someone with access to the policy — which do you prefer?
There was a problem hiding this comment.
Updating the GCLB URL map and Cloud Armor policy in Terraform is fine — let's keep /api/packages/<package>/versions/<version>/attestation and option (a) (POST /api/packages/versions/newUploadAttestation/<uploadId>). Please file a tracking issue (or note it in the rollout checklist) so we remember to land the gclb.tf and security_policy.tf rules before enabling this in prod.
One small thing in publishUploadedBlob (app/lib/package/backend.dart lines 1503–1528, 1792–1806): for the archive, we copy uploadObjectName to workObjectName first so the client cannot overwrite tmp/$uploadGuid between verification and copying to _canonicalBucket. For the attestation, publishUploadedBlob reads attestationObjectName (which #9546 verifies) and then _performTarballUpload copies attestationObjectName directly from _incomingBucket, so a concurrent POST /api/packages/versions/newUploadAttestation/<uploadId> could swap the bundle after verification. Since we already read the bytes into memory in publishUploadedBlob (≤128 KB), we can either upload those exact bytes to _canonicalBucket in _performTarballUpload or copy attestationObjectName to a workAttestationObjectName upfront.
First PR in the stack for package attestation support: - Adds `AssetKind.attestation` for storing attestation bundles as `PackageVersionAsset` entities in Datastore. - `GET /api/packages/versions/new` now returns an `attestationUrl`, pointing at `POST /api/packages/versions/newUploadAttestation/<uploadId>`. Repositories that do not support publishing with attestations omit the property, which is how `pub` detects support before uploading the archive (dart-lang/pub#4876). - The new endpoint requires an authenticated client, validates that the upload id is a uuid, that the bundle is at most `maxAttestationContentLength` (128 KB), and that it parses as a JSON object, and then stores it as `tmp/<guid>.attestation.json` in the incoming bucket. - `packageBackend.publishUploadedBlob` reads that object if it is present, saves it as an attestation asset, and removes it together with the other temporary objects. `newUploadFinish` remains a `GET`-only endpoint. - Attestations are not passed through `capContent()`: oversized bundles are rejected when they are uploaded rather than silently truncated. - Exposes `GET /api/packages/<package>/versions/<version>/attestation` for client retrieval, updates `PubApiClientExt` and adds unit tests. Squash of the previous commits of this branch, rebased onto `main`.
6269086 to
e03dac8
Compare
Review follow-ups on the storage and serving of attestation bundles: - The attestation is canonical data (uploaded alongside the archive, not extracted from it), so `_performTarballUpload` copies it into the canonical bucket next to the archive, as `packages/<package>-<version>.tar.gz.sigstore.json`, and it is no longer stored as a `PackageVersionAsset`. `removePackageTarball` deletes both objects, `listVersionsInCanonicalBucket` filters by the `.tar.gz` suffix and `listAttestationsInCanonicalBucket` lists the bundles. - `ApiExporter.synchronizePackage` exports the attestations to the exported API bucket as `/api/packages/<package>/versions/<version>/attestation`, like the archives (shared implementation with `synchronizeTarballs`), including GC and deletion on moderation. Attestations are synchronized before the version listing is written, as clients fetch the attestation when the listing advertises one and do not retry. - The `GET .../attestation` handler is kept for hosts that do not route the path to the bucket; it now checks package and version visibility and serves the canonical copy. - The attestation upload handler reads the request body with a size cap (`foldBytes(maxSize:)`) instead of buffering it before checking.
62e73f0 to
55e7be0
Compare
The attestation is no longer a PackageVersionAsset (it lives in the canonical bucket, see #9545) and the facts pub.dev verified at upload are persisted on the PackageVersion (#9546). The Security tab is now rendered from those, instead of re-deriving them from the bundle (including the ad-hoc ASN.1 string scan of the certificate), and the security page no longer loads an asset.
…4876) Adds client support for publishing package attestations during `pub publish`: - Adds `--with-attestation <bundle.sigstore.json>` flag to `dart pub publish --from-archive`. - Enforces that a repository is specified in `pubspec.yaml` (`repository` field) when publishing with an attestation. - Validates that `--with-attestation` can only be used with `--from-archive`, and that the file is non-empty and contains a JSON object, before asking the user to confirm the publication. ### Protocol `doc/repository-spec-v2.md` gets a new *Publishing with an Attestation* section: - A repository that supports publishing with attestations returns an `<attestation-upload-url>` in the `attestationUrl` property of the response from `<hosted-url>/api/packages/versions/new`. A repository that does not support it simply omits the property. - The client `POST`s the bundle as `application/json` to `<attestation-upload-url>` **before** uploading the package archive, attaching `Authorization` under the same prefix rule as `archive_url`. - `<finalize-upload-url>` remains a plain `GET`. `attestationUrl` is an opaque URL: a repository may point it at its own endpoint or at a signed URL on a storage service. On pub.dev it is an endpoint on the app server, so no second signed upload policy is needed (see dart-lang/pub-dev#9545). Server side: dart-lang/pub-dev#9545
Note
This PR was generated by an AI coding agent (Jetski) on behalf of @mosuem.
First PR in stack for package attestation support:
GET /api/packages/versions/newnow returns anattestationUrl, pointing atPOST /api/packages/versions/newUploadAttestation/<uploadId>. Repositories that do not support publishing with attestations omit the property, which is howpubdetects support before uploading the archive (Support uploading package attestations during publish --from-archive pub#4876).maxAttestationContentLength(128 KB, enforced while reading the request body viafoldBytes(maxSize:)), and that it parses as a JSON object, and then stores it astmp/<guid>.attestation.jsonin the incoming bucket.newUploadFinishremains aGET-only endpoint._performTarballUploadcopies it into the canonical bucket next to the archive, aspackages/<package>-<version>.tar.gz.sigstore.json. It is not stored as aPackageVersionAsset.removePackageTarballremoves both objects.ApiExporter.synchronizePackage, as/api/packages/<package>/versions/<version>/attestation(verbatim bytes), like the archives:ExportedPackage.synchronizeAttestationsmirrorssynchronizeTarballs, moderated/removed versions get their export deleted, and attestations are synchronized before the version listing is written, since clients fetch the attestation when the listing says a version has one and do not retry. Serving this path from the bucket needs a GCLB URL map change (ops).GET /api/packages/<package>/versions/<version>/attestationis kept as the handler for hosts that do not route the path to the bucket. It checks package and version visibility (404 for moderated content) and serves the canonical copy.PubApiClientExtand adds tests inupload_test.dart(storage, export, verbatim retrieval, moderation),exported_api_test.dartandutils_test.dart.Since the attestation is uploaded to the app server, publishing with an attestation does not require a second signed upload policy, and therefore no extra
iam.signBlobcall per publish.Verifying the bundle against the uploaded archive (digest and provenance) follows in the next PR in the stack.