Skip to content
21 changes: 21 additions & 0 deletions app/lib/frontend/handlers/pubapi.client.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

58 changes: 58 additions & 0 deletions app/lib/frontend/handlers/pubapi.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import 'package:shelf_router/shelf_router.dart';
import '../../account/consent_backend.dart';
import '../../admin/backend.dart';
import '../../package/backend.dart' hide InviteStatus;
import '../../package/models.dart' show AssetKind;
import '../../publisher/backend.dart';
import '../../shared/exceptions.dart';
import '../../shared/handlers.dart';
Expand Down Expand Up @@ -80,6 +81,31 @@ class PubApi {
);
}

/// Fetches the Sigstore attestation bundle for a specific (package, version) pair.
@EndPoint.get('/api/packages/<package>/versions/<version>/attestation')
Future<Response> getPackageVersionAttestation(
Request request,
String package,
String version,
) async {
checkPackageVersionParams(package, version);
final asset = await packageBackend.lookupPackageVersionAsset(
package,
version,
AssetKind.attestation,
);
Comment on lines +85 to +96

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.

If we keep a handler here (or as a fallback/redirect similar to fetchPackage), note that lookupPackageVersionAsset does not check whether the package or version has been moderated (isPackageVisible(package) / pv.isNotVisible), so it would still return 200 for moderated packages/versions rather than 404.

if (asset == null || asset.textContent == null) {
throw NotFoundException.resource('attestation for $package $version');
}
return Response.ok(
asset.textContent,
headers: {
'content-type': 'application/json; charset="utf-8"',
...CacheControl.clientApi.headers,
},
);
}

/// Downloading package.
///
/// This is the endpoint we link to from the version listing.
Expand Down Expand Up @@ -132,7 +158,39 @@ class PubApi {
// integration tests before we switch traffic.
await packageBackend.startUpload(
request.requestedUri.resolve('/api/packages/versions/newUploadFinish'),
attestationUrlPrefix: request.requestedUri.resolve(
'/api/packages/versions/newUploadAttestation',
),
);

/// Upload an attestation for a package archive that is being uploaded.
///
/// The attestation is uploaded before the package archive, and is verified
/// and stored when the upload is finished.
/// https://github.com/dart-lang/pub/blob/master/doc/repository-spec-v2.md#publishing-with-an-attestation
///
/// POST /api/packages/versions/newUploadAttestation/<uploadId>
/// <attestation JSON bundle>
/// [200 OK]
/// {
/// "success" : {
/// "message": "Attestation uploaded.",
/// },
/// }
@EndPoint.post('/api/packages/versions/newUploadAttestation/<uploadId>')
Future<SuccessMessage> uploadPackageAttestation(
Request request,
String uploadId,
) async {
final bytes = await request.read().expand((i) => i).toList();
if (bytes.isEmpty) {
throw PackageRejectedException(
'Invalid attestation bundle format: the request body is empty.',
);
}
await packageBackend.uploadAttestation(uploadId, bytes);
Comment on lines +181 to +191

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.

await request.read().expand((i) => i).toList() reads the entire request stream into memory (and unboxes/reboxes every byte) before uploadAttestation checks bytes.length > maxAttestationContentLength.

We have ByteFolderExt.foldBytes() on Stream<List<int>> in app/lib/shared/utils.dart (which uses BytesBuilder(copy: false)). Consider adding an optional {int? maxSize} check to foldBytes (similar to BucketExt.readAsBytes in app/lib/shared/storage.dart) so we abort reading the stream as soon as it exceeds maxAttestationContentLength.

return SuccessMessage(success: Message(message: 'Attestation uploaded.'));
}

/// Finish async upload.
/// TODO: Link to the spec once it has the details updated:
Expand Down
35 changes: 35 additions & 0 deletions app/lib/frontend/handlers/pubapi.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

108 changes: 106 additions & 2 deletions app/lib/package/backend.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// BSD-style license that can be found in the LICENSE file.

import 'dart:async';
import 'dart:convert';
import 'dart:io';

import 'package:_pub_shared/data/account_api.dart' as account_api;
Expand Down Expand Up @@ -57,10 +58,22 @@ import 'upload_signer_service.dart';
// that is stored separately in the database.
final maxAssetContentLength = 256 * 1024;

// The maximum length of an attestation bundle.
//
// Note: unlike other assets, attestations are rejected when they are longer
// than this, as a truncated attestation could never be verified.
final maxAttestationContentLength = 128 * 1024;

/// The maximum number of versions a package is allowed to have.
final _defaultMaxVersionsPerPackage = 1000;

final Logger _logger = Logger('pub.cloud_repository');

/// Matches the UUIDs created by `createUuid()`, used to identify uploads.
final _uuidRegExp = RegExp(
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
caseSensitive: false,
);
final _validGitHubUserOrRepoRegExp = RegExp(
r'^[a-z0-9\-\._]+$',
caseSensitive: false,
Expand Down Expand Up @@ -1036,7 +1049,16 @@ class PackageBackend {
return pv.toApiVersionInfo();
}

Future<api.UploadInfo> startUpload(Uri redirectUrl) async {
/// Starts an upload, returning the parameters the client needs to upload the
/// package archive.
///
/// [redirectUrl] is the URL the client is redirected to when the archive has
/// been uploaded, and [attestationUrlPrefix] is the URL prefix where the
/// client may upload an attestation of the archive.
Future<api.UploadInfo> startUpload(
Uri redirectUrl, {
required Uri attestationUrlPrefix,
}) async {
final restriction = await getUploadRestrictionStatus();
if (restriction == UploadRestrictionStatus.noUploads) {
throw PackageRejectedException.uploadRestricted();
Expand All @@ -1058,12 +1080,57 @@ class PackageBackend {
_logger.info(
'Redirecting pub client to google cloud storage (uuid: $guid)',
);
return uploadSigner.buildUpload(
final uploadInfo = await uploadSigner.buildUpload(
bucket,
object,
lifetime,
successRedirectUrl: '$url',
);
return api.UploadInfo(
url: uploadInfo.url,
fields: uploadInfo.fields,
attestationUrl: '$attestationUrlPrefix/$guid',
);
}

/// Stores the attestation [bytes] uploaded for the upload with [uploadGuid].
///
/// The attestation is verified and stored with the package version when the
/// upload is finished, see [publishUploadedBlob].
Future<void> uploadAttestation(String uploadGuid, List<int> bytes) async {
final restriction = await getUploadRestrictionStatus();
if (restriction == UploadRestrictionStatus.noUploads) {
throw PackageRejectedException.uploadRestricted();
}
await requireAuthenticatedClient();
InvalidInputException.check(
_uuidRegExp.hasMatch(uploadGuid),
'Invalid upload id.',
);
if (bytes.length > maxAttestationContentLength) {
throw PackageRejectedException(
'Attestation bundle is too large '
'(max $maxAttestationContentLength bytes).',
);
}
// Verify that the bundle is a JSON object before storing it. It can only be
// verified against the archive when the upload is finished.
try {
final decoded = jsonDecode(utf8.decode(bytes));
if (decoded is! Map<String, dynamic>) {
throw FormatException('Attestation bundle must be a JSON object.');
}
} on FormatException catch (e) {
throw PackageRejectedException('Invalid attestation bundle format: $e');
}

_logger.info('Uploading attestation (uuid: $uploadGuid).');
await uploadWithRetry(
_incomingBucket,
tmpAttestationObjectName(uploadGuid),
bytes.length,
() => Stream.value(bytes),
);
}

/// Finishes the upload of a package and returns the list of messages
Expand All @@ -1080,6 +1147,7 @@ class PackageBackend {
return await withTempDirectory((Directory dir) async {
// Check the existence of the uploaded file
final uploadObjectName = tmpObjectName(uploadGuid);
final attestationObjectName = tmpAttestationObjectName(uploadGuid);
final info = await _incomingBucket.tryInfo(uploadObjectName);
if (info?.length == null) {
throw PackageRejectedException.archiveEmpty();
Expand Down Expand Up @@ -1208,12 +1276,26 @@ class PackageBackend {
throw PackageRejectedException.dependencyDoesNotExists(name);
}

// Read the attestation, if one was uploaded for this upload.
// Note: the content has been validated to be a JSON object when it was
// uploaded, see [uploadAttestation].
String? attestationContent;
if (await _incomingBucket.tryInfo(attestationObjectName) != null) {
attestationContent = utf8.decode(
await _incomingBucket.readAsBytes(
attestationObjectName,
maxSize: maxAttestationContentLength,
),
);
}

sw.reset();
final entities = await _createUploadEntities(
db,
agent,
archive,
sha256Hash: sha256Hash,
attestationContent: attestationContent,
);
final (version, uploadMessages) = await _performTarballUpload(
entities: entities,
Expand All @@ -1229,6 +1311,9 @@ class PackageBackend {
sw.reset();
await _incomingBucket.deleteWithRetry(uploadObjectName);
await _incomingBucket.deleteWithRetry(workObjectName);
if (attestationContent != null) {
await _incomingBucket.deleteWithRetry(attestationObjectName);
}
_logger.info('Temporary object removed in ${sw.elapsed}.');
return [
'Successfully uploaded '
Expand Down Expand Up @@ -2368,6 +2453,7 @@ Future<_UploadEntities> _createUploadEntities(
AuthenticatedAgent agent,
PackageSummary archive, {
required List<int> sha256Hash,
String? attestationContent,
}) async {
final pubspec = Pubspec.fromYaml(archive.pubspecContent!);
final packageKey = db.emptyKey.append(Package, id: pubspec.name);
Expand All @@ -2387,6 +2473,7 @@ Future<_UploadEntities> _createUploadEntities(
final derived = derivePackageVersionEntities(
archive: archive,
versionCreated: version.created!,
attestationContent: attestationContent,
);

// TODO: verify if assets sizes are within the transaction limit (10 MB)
Expand All @@ -2397,6 +2484,7 @@ Future<_UploadEntities> _createUploadEntities(
DerivedPackageVersionEntities derivePackageVersionEntities({
required PackageSummary archive,
required DateTime versionCreated,
String? attestationContent,
}) {
final pubspec = Pubspec.fromYaml(archive.pubspecContent!);
final key = QualifiedVersionKey(
Expand Down Expand Up @@ -2455,6 +2543,17 @@ DerivedPackageVersionEntities derivePackageVersionEntities({
path: archive.licensePath,
textContent: capContent(archive.licenseContent),
),
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,
),
Comment on lines +2546 to +2556

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.

Storing the attestation only as a PackageVersionAsset and serving it directly from the database in getPackageVersionAttestation has a few architectural issues:

  1. Canonical vs. derived storage: PackageVersionAsset is for derived data extracted from the package tarball (derivePackageVersionEntities for README, CHANGELOG, pubspec, license; see doc/entities.md). Because the attestation bundle is uploaded alongside the tarball rather than inside it, it is canonical data. If we only store it in PackageVersionAsset and delete tmp/<guid>.attestation.json from _incomingBucket, it is never stored in _canonicalBucket (packages/<package>-<version>...), and re-running derivePackageVersionEntities from the canonical archive would lose the attestation. It should be copied into _canonicalBucket alongside the tarball in _performTarballUpload.
  2. Serving via ExportedApi / GCS: GET /api/packages/<package>/versions/<version>/attestation will be called during dart pub get (in pub#4875). All other pub get endpoints (/api/packages/<pkg>, /api/packages/<pkg>/advisories, /api/archives/...) are exported to _exportedApiBucket via ExportedApi (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 via ExportedApi also means ApiExporter.synchronizePackage will automatically remove exported attestations if a package or version is moderated (lookupPackageVersionAsset currently does not check isPackageVisible / pv.isNotVisible).
  3. Cloud Armor POST rate limit on upload: In production, Cloud Armor rate-limits non-GET requests to pub.dev to 5 requests per 3 minutes per IP (which wasn't hit by pub publish previously because the tarball POST goes to storage.googleapis.com, while new and newUploadFinish on pub.dev are GET requests with higher per-route limits). Sending POST /api/packages/versions/newUploadAttestation/<uploadId> to pub.dev will 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).

];

final versionInfo = PackageVersionInfo()
Expand All @@ -2473,6 +2572,11 @@ DerivedPackageVersionEntities derivePackageVersionEntities({
@visibleForTesting
String tmpObjectName(String guid) => 'tmp/$guid';

/// The GCS object name of the attestation uploaded for the temporary object
/// [guid] - excluding leading '/'.
@visibleForTesting
String tmpAttestationObjectName(String guid) => 'tmp/$guid.attestation.json';

/// Verify that the [package] and the optional [version] parameter looks as acceptable input.
void checkPackageVersionParams(String package, [String? version]) {
InvalidInputException.checkPackageName(package);
Expand Down
1 change: 1 addition & 0 deletions app/lib/package/models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -765,6 +765,7 @@ abstract class AssetKind {
static const changelog = 'changelog';
static const example = 'example';
static const license = 'license';
static const attestation = 'attestation';
}

/// A derived entity that holds extracted asset of a [PackageVersion] archive.
Expand Down
Loading
Loading