Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 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.

26 changes: 26 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,
);
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
18 changes: 18 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.

71 changes: 71 additions & 0 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 All @@ -29,6 +30,7 @@ import 'package:pub_dev/task/backend.dart';
import 'package:pub_package_reader/pub_package_reader.dart';
import 'package:pub_semver/pub_semver.dart';
import 'package:pubspec_parse/pubspec_parse.dart' as pubspec_parse;
import 'package:sigstore/sigstore.dart';

import '../account/agent.dart';
import '../account/backend.dart';
Expand Down Expand Up @@ -1208,12 +1210,66 @@ class PackageBackend {
throw PackageRejectedException.dependencyDoesNotExists(name);
}

// Check for an accompanying Sigstore attestation bundle in the incoming bucket.
String? attestationContent;
final attestationObjectName =
'${tmpObjectName(uploadGuid)}.sigstore.json';
final attestationInfo = await _incomingBucket.tryInfo(
attestationObjectName,
);
if (attestationInfo?.length != null) {
_logger.info('Verifying package attestation ($uploadGuid).');
final attestationFilename =
'${dir.absolute.path}/attestation.sigstore.json';
await _incomingBucket.readWithRetry(
attestationObjectName,
(input) => _saveTarballToFS(input, attestationFilename),
);
attestationContent = await File(attestationFilename).readAsString();
try {
final attestationJson =
jsonDecode(attestationContent) as Map<String, dynamic>;
final bundle = SigstoreBundle.fromJson(attestationJson);
final verifier = AttestationVerifier();
final archiveBytes = await file.readAsBytes();
final verificationResult = verifier.verify(
packageName: pubspec.name,
packageVersion: Version.parse(versionString),
archiveBytes: archiveBytes,
bundle: bundle,
pubspecRepository: pubspec.repository?.toString(),
);
if (!verificationResult.isValid) {
throw PackageRejectedException(
'Invalid package attestation: '
'${verificationResult.errors.join(', ')}',
);
}
_logger.info(
'Attestation verified successfully for ${pubspec.name} $versionString '
'from repository ${verificationResult.repository}.',
);
} on PackageRejectedException {
rethrow;
} catch (e, st) {
_logger.warning(
'Failed to parse or verify attestation bundle.',
e,
st,
);
throw PackageRejectedException(
'Failed to verify package attestation: $e',
);
}
}

sw.reset();
final entities = await _createUploadEntities(
db,
agent,
archive,
sha256Hash: sha256Hash,
attestationContent: attestationContent,
);
final (version, uploadMessages) = await _performTarballUpload(
entities: entities,
Expand All @@ -1229,6 +1285,9 @@ class PackageBackend {
sw.reset();
await _incomingBucket.deleteWithRetry(uploadObjectName);
await _incomingBucket.deleteWithRetry(workObjectName);
if (attestationInfo?.length != null) {
await _incomingBucket.deleteWithRetry(attestationObjectName);
}
_logger.info('Temporary object removed in ${sw.elapsed}.');
return [
'Successfully uploaded '
Expand Down Expand Up @@ -2368,6 +2427,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 +2447,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 +2458,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 +2517,15 @@ 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',
textContent: capContent(attestationContent),
),
];

final versionInfo = PackageVersionInfo()
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
33 changes: 29 additions & 4 deletions app/lib/tool/utils/pub_api_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,10 @@ bool _retryIf(Exception e) {

extension PubApiClientExt on PubApiClient {
@visibleForTesting
Future<String> preparePackageUpload(List<int> bytes) async {
Future<String> preparePackageUpload(
List<int> bytes, {
List<int>? attestationBytes,
}) async {
final uploadInfo = await getPackageUploadUrl();

final request = http.MultipartRequest('POST', Uri.parse(uploadInfo.url))
Expand All @@ -197,12 +200,34 @@ extension PubApiClientExt on PubApiClient {
final callbackUri = Uri.parse(
uploadInfo.fields!['success_action_redirect']!,
);
return callbackUri.queryParameters['upload_id']!;
final uploadId = callbackUri.queryParameters['upload_id']!;

if (attestationBytes != null) {
final baseKey = uploadInfo.fields!['key']!;
final attestationFields = Map<String, String>.from(uploadInfo.fields!);
attestationFields['key'] = '$baseKey.sigstore.json';
attestationFields.remove('success_action_redirect');
final attRequest =
http.MultipartRequest('POST', Uri.parse(uploadInfo.url))
..headers[fakeClockHeaderName] = clock.now().toIso8601String()
..fields.addAll(attestationFields)
..files.add(http.MultipartFile.fromBytes('file', attestationBytes))
..followRedirects = false;
await attRequest.send();
}

return uploadId;
}

@visibleForTesting
Future<SuccessMessage> uploadPackageBytes(List<int> bytes) async {
final uploadId = await preparePackageUpload(bytes);
Future<SuccessMessage> uploadPackageBytes(
List<int> bytes, {
List<int>? attestationBytes,
}) async {
final uploadId = await preparePackageUpload(
bytes,
attestationBytes: attestationBytes,
);
return await finishPackageUpload(uploadId);
}
}
5 changes: 5 additions & 0 deletions app/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ dependencies:
ulid: '2.0.1'
tar: '2.0.2'
api_builder:
sigstore:
git:
url: https://github.com/dart-lang/labs.git
ref: add-package-sigstore
path: pkgs/sigstore

dev_dependencies:
build_runner: '^2.0.0'
Expand Down
Loading
Loading