From c37f9ca7de37ed2d5b9018a572d1395de5d0165e Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 19 Aug 2026 14:18:03 +0000 Subject: [PATCH 1/2] Accept, verify, and serve Sigstore package attestations Implements Sigstore attestation support in pub-dev using package:sigstore: - Adds `AssetKind.attestation` for storing attestation bundles as package version assets in Datastore. - Exposes `GET /api/packages//versions//attestation` endpoint to serve attestation bundles. - In `packageBackend.publishUploadedBlob`, checks for accompanying `tmp/.sigstore.json` in the incoming bucket, verifies it against the uploaded package archive, name, version, and pubspec repository using `AttestationVerifier`, and rejects uploads with invalid attestations. - Updates `PubApiClientExt` and adds unit tests in `upload_test.dart`. --- app/lib/frontend/handlers/pubapi.client.dart | 10 ++ app/lib/frontend/handlers/pubapi.dart | 26 +++ app/lib/frontend/handlers/pubapi.g.dart | 18 ++ app/lib/package/backend.dart | 71 ++++++++ app/lib/package/models.dart | 1 + app/lib/tool/utils/pub_api_client.dart | 33 +++- app/pubspec.yaml | 5 + app/test/package/upload_test.dart | 165 +++++++++++++++++++ pubspec.lock | 7 + pubspec.yaml | 4 + 10 files changed, 336 insertions(+), 4 deletions(-) diff --git a/app/lib/frontend/handlers/pubapi.client.dart b/app/lib/frontend/handlers/pubapi.client.dart index 951c4edd9b..96e4ef6923 100644 --- a/app/lib/frontend/handlers/pubapi.client.dart +++ b/app/lib/frontend/handlers/pubapi.client.dart @@ -59,6 +59,16 @@ class PubApiClient { ); } + Future> getPackageVersionAttestation( + String package, + String version, + ) async { + return await _client.requestBytes( + verb: 'get', + path: '/api/packages/$package/versions/$version/attestation', + ); + } + Future> fetchPackage(String package, String version) async { return await _client.requestBytes( verb: 'get', diff --git a/app/lib/frontend/handlers/pubapi.dart b/app/lib/frontend/handlers/pubapi.dart index d67e757549..b0e095d081 100644 --- a/app/lib/frontend/handlers/pubapi.dart +++ b/app/lib/frontend/handlers/pubapi.dart @@ -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'; @@ -80,6 +81,31 @@ class PubApi { ); } + /// Fetches the Sigstore attestation bundle for a specific (package, version) pair. + @EndPoint.get('/api/packages//versions//attestation') + Future 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. diff --git a/app/lib/frontend/handlers/pubapi.g.dart b/app/lib/frontend/handlers/pubapi.g.dart index b0b0e7bc87..b78b78ea9c 100644 --- a/app/lib/frontend/handlers/pubapi.g.dart +++ b/app/lib/frontend/handlers/pubapi.g.dart @@ -75,6 +75,24 @@ Router _$PubApiRouter(PubApi service) { return $utilities.unhandledError(e, st); } }); + router.add('GET', r'/api/packages//versions//attestation', ( + Request request, + String package, + String version, + ) async { + try { + final _$result = await service.getPackageVersionAttestation( + request, + package, + version, + ); + return _$result; + } on ApiResponseException catch (e) { + return e.asApiResponse(); + } catch (e, st) { + return $utilities.unhandledError(e, st); + } + }); router.add('GET', r'/api/archives/-.tar.gz', ( Request request, String package, diff --git a/app/lib/package/backend.dart b/app/lib/package/backend.dart index 9063ea72d7..51472ac9f5 100644 --- a/app/lib/package/backend.dart +++ b/app/lib/package/backend.dart @@ -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; @@ -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'; @@ -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; + 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, @@ -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 ' @@ -2368,6 +2427,7 @@ Future<_UploadEntities> _createUploadEntities( AuthenticatedAgent agent, PackageSummary archive, { required List sha256Hash, + String? attestationContent, }) async { final pubspec = Pubspec.fromYaml(archive.pubspecContent!); final packageKey = db.emptyKey.append(Package, id: pubspec.name); @@ -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) @@ -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( @@ -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() diff --git a/app/lib/package/models.dart b/app/lib/package/models.dart index db3c65502b..af8ea87846 100644 --- a/app/lib/package/models.dart +++ b/app/lib/package/models.dart @@ -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. diff --git a/app/lib/tool/utils/pub_api_client.dart b/app/lib/tool/utils/pub_api_client.dart index dd9270dde2..4eeb152b3e 100644 --- a/app/lib/tool/utils/pub_api_client.dart +++ b/app/lib/tool/utils/pub_api_client.dart @@ -174,7 +174,10 @@ bool _retryIf(Exception e) { extension PubApiClientExt on PubApiClient { @visibleForTesting - Future preparePackageUpload(List bytes) async { + Future preparePackageUpload( + List bytes, { + List? attestationBytes, + }) async { final uploadInfo = await getPackageUploadUrl(); final request = http.MultipartRequest('POST', Uri.parse(uploadInfo.url)) @@ -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.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 uploadPackageBytes(List bytes) async { - final uploadId = await preparePackageUpload(bytes); + Future uploadPackageBytes( + List bytes, { + List? attestationBytes, + }) async { + final uploadId = await preparePackageUpload( + bytes, + attestationBytes: attestationBytes, + ); return await finishPackageUpload(uploadId); } } diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 424358946f..fad52b930b 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -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' diff --git a/app/test/package/upload_test.dart b/app/test/package/upload_test.dart index fcb0da224b..fe07f321a7 100644 --- a/app/test/package/upload_test.dart +++ b/app/test/package/upload_test.dart @@ -3,9 +3,11 @@ // BSD-style license that can be found in the LICENSE file. import 'dart:async'; +import 'dart:convert'; import 'package:_pub_shared/data/package_api.dart'; import 'package:clock/clock.dart'; +import 'package:crypto/crypto.dart'; import 'package:gcloud/db.dart'; import 'package:gcloud/storage.dart'; import 'package:pub_dev/account/backend.dart'; @@ -1641,5 +1643,168 @@ void main() { ); }, ); + + testWithProfile( + 'successful upload with valid attestation bundle and api retrieval', + fn: () async { + final pubspecContent = + 'name: attested_pkg\nversion: 1.0.0\nrepository: https://github.com/mosuem/attested_pkg\nenvironment:\n sdk: ">=2.12.0 <4.0.0"\n'; + final archiveBytes = await packageArchiveBytes( + pubspecContent: pubspecContent, + ); + final archiveSha = sha256.convert(archiveBytes).toString(); + + final statement = { + '_type': 'https://in-toto.io/Statement/v1', + 'subject': [ + { + 'name': 'attested_pkg-1.0.0.tar.gz', + 'digest': {'sha256': archiveSha}, + }, + ], + 'predicateType': 'https://slsa.dev/provenance/v1', + 'predicate': { + 'buildDefinition': { + 'buildType': 'https://actions.github.io/buildtypes/workflow/v1', + 'externalParameters': { + 'workflow': { + 'ref': 'refs/tags/v1.0.0', + 'repository': 'https://github.com/mosuem/attested_pkg', + 'path': '.github/workflows/publish.yaml', + }, + }, + 'resolvedDependencies': [ + { + 'uri': + 'git+https://github.com/mosuem/attested_pkg@refs/tags/v1.0.0', + 'digest': { + 'gitCommit': '7891abbe3dab159e9d0187fc1042d5e0cd82cfad', + }, + }, + ], + }, + 'runDetails': { + 'builder': { + 'id': + 'https://github.com/dart-lang/ecosystem/.github/workflows/publish.yaml@refs/heads/main', + }, + }, + }, + }; + + final derBytes = [ + 0x30, + 0x82, + 0x01, + 0x00, + ...utf8.encode('https://github.com/mosuem/attested_pkg'), + ...utf8.encode('https://token.actions.githubusercontent.com'), + ]; + + final bundleJson = { + 'mediaType': 'application/vnd.dev.sigstore.bundle.v0.3+json', + 'verificationMaterial': { + 'certificate': {'rawBytes': base64Encode(derBytes)}, + 'tlogEntries': [ + { + 'logIndex': '123456', + 'inclusionProof': { + 'rootHash': 'test-root-hash', + 'hashes': ['hash1', 'hash2'], + }, + }, + ], + }, + 'dsseEnvelope': { + 'payloadType': 'application/vnd.in-toto+json', + 'payload': base64Encode(utf8.encode(jsonEncode(statement))), + 'signatures': [ + {'sig': base64Encode(utf8.encode('test-signature'))}, + ], + }, + }; + + final attestationBytes = utf8.encode(jsonEncode(bundleJson)); + final client = createPubApiClient(authToken: adminClientToken); + final message = await client.uploadPackageBytes( + archiveBytes, + attestationBytes: attestationBytes, + ); + expect(message.success.message, contains('Successfully uploaded')); + + // Verify attestation asset was stored in Datastore + final asset = await packageBackend.lookupPackageVersionAsset( + 'attested_pkg', + '1.0.0', + AssetKind.attestation, + ); + expect(asset, isNotNull); + expect(asset!.textContent, isNotNull); + final storedJson = + jsonDecode(asset.textContent!) as Map; + expect( + storedJson['mediaType'], + equals('application/vnd.dev.sigstore.bundle.v0.3+json'), + ); + }, + ); + + testWithProfile( + 'upload fails when attestation is invalid or sha256 does not match', + fn: () async { + final pubspecContent = + 'name: invalid_attested_pkg\nversion: 1.0.0\nrepository: https://github.com/mosuem/invalid_attested_pkg\nenvironment:\n sdk: ">=2.12.0 <4.0.0"\n'; + final archiveBytes = await packageArchiveBytes( + pubspecContent: pubspecContent, + ); + + // Mismatched hash + const fakeSha = + '0000000000000000000000000000000000000000000000000000000000000000'; + final statement = { + '_type': 'https://in-toto.io/Statement/v1', + 'subject': [ + { + 'name': 'invalid_attested_pkg-1.0.0.tar.gz', + 'digest': {'sha256': fakeSha}, + }, + ], + 'predicateType': 'https://slsa.dev/provenance/v1', + 'predicate': {}, + }; + final bundleJson = { + 'mediaType': 'application/vnd.dev.sigstore.bundle.v0.3+json', + 'verificationMaterial': { + 'certificate': { + 'rawBytes': base64Encode([0x30, 0x00]), + }, + 'tlogEntries': [ + { + 'logIndex': '123', + 'inclusionProof': {'hashes': []}, + }, + ], + }, + 'dsseEnvelope': { + 'payloadType': 'application/vnd.in-toto+json', + 'payload': base64Encode(utf8.encode(jsonEncode(statement))), + 'signatures': [ + {'sig': base64Encode(utf8.encode('sig'))}, + ], + }, + }; + + final attestationBytes = utf8.encode(jsonEncode(bundleJson)); + final rs = createPubApiClient( + authToken: adminClientToken, + ).uploadPackageBytes(archiveBytes, attestationBytes: attestationBytes); + await expectApiException( + rs, + status: 400, + code: 'PackageRejected', + message: 'Invalid package attestation', + ); + }, + ); }); } diff --git a/pubspec.lock b/pubspec.lock index 6e8bae3dbe..0594983a5b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -769,6 +769,13 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + sigstore: + dependency: "direct overridden" + description: + path: "../labs/pkgs/sigstore" + relative: true + source: path + version: "0.1.0-dev" slugid: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 396c24ae61..5718af6fe7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -22,3 +22,7 @@ workspace: dev_dependencies: lints: ^6.0.0 + +dependency_overrides: + sigstore: + path: ../labs/pkgs/sigstore From a54ba8175bab7e7fb4a070bea64465a0352c4bf4 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 19 Aug 2026 14:24:26 +0000 Subject: [PATCH 2/2] Use git dependency for package:sigstore without local overrides --- pubspec.lock | 10 ++++++---- pubspec.yaml | 4 ---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 0594983a5b..7ad3253ffc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -770,11 +770,13 @@ packages: source: hosted version: "3.0.0" sigstore: - dependency: "direct overridden" + dependency: transitive description: - path: "../labs/pkgs/sigstore" - relative: true - source: path + path: "pkgs/sigstore" + ref: add-package-sigstore + resolved-ref: f1bb450c8234ff8c2a0eaaa4b5061e754822062b + url: "https://github.com/dart-lang/labs.git" + source: git version: "0.1.0-dev" slugid: dependency: transitive diff --git a/pubspec.yaml b/pubspec.yaml index 5718af6fe7..396c24ae61 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -22,7 +22,3 @@ workspace: dev_dependencies: lints: ^6.0.0 - -dependency_overrides: - sigstore: - path: ../labs/pkgs/sigstore