From 1e706c1d4277ff44a45006557b7f130e2c4defa2 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 19 Aug 2026 12:41:07 +0000 Subject: [PATCH 01/12] Support loading Sigstore trusted_root.json from SDK and overrides Adds loadTrustedRoot() and loadTrustedRootJson() in package:pub to resolve the Sigstore root of trust from: 1. Explicit override paths passed to the loader. 2. The PUB_SIGSTORE_TRUST_ROOT environment variable. 3. The built Dart SDK distribution at lib/_internal/sigstore/trusted_root.json. 4. The Dart SDK repository checkout at third_party/sigstore/trusted_root.json. --- lib/src/sigstore/trusted_root.dart | 76 ++++++++++++++++++++++++++++ test/sigstore/trusted_root_test.dart | 49 ++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 lib/src/sigstore/trusted_root.dart create mode 100644 test/sigstore/trusted_root_test.dart diff --git a/lib/src/sigstore/trusted_root.dart b/lib/src/sigstore/trusted_root.dart new file mode 100644 index 00000000000..ae77e8bb64a --- /dev/null +++ b/lib/src/sigstore/trusted_root.dart @@ -0,0 +1,76 @@ +// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:convert'; + +import '../exceptions.dart'; +import '../io.dart'; +import '../path.dart'; +import '../platform_info.dart'; +import '../sdk/dart.dart'; + +/// Loads the Sigstore `trusted_root.json` root of trust. +/// +/// Looks in the following order: +/// 1. An explicit override path passed in [overridePath]. +/// 2. The `PUB_SIGSTORE_TRUST_ROOT` environment variable. +/// 3. The built SDK directory at `lib/_internal/sigstore/trusted_root.json`. +/// 4. The Dart repository checkout at `third_party/sigstore/trusted_root.json`. +String loadTrustedRootJson({String? overridePath}) { + if (overridePath != null) { + if (!fileExists(overridePath)) { + throw DataException( + 'Could not find Sigstore trusted root file at "$overridePath".', + ); + } + return readTextFile(overridePath); + } + + if (platform.environment['PUB_SIGSTORE_TRUST_ROOT'] case final envPath?) { + if (!fileExists(envPath)) { + throw DataException( + 'Could not find Sigstore trusted root file at "$envPath" ' + 'specified by PUB_SIGSTORE_TRUST_ROOT.', + ); + } + return readTextFile(envPath); + } + + // 1. Check in the built SDK layout: + final sdkPath = p.join( + DartSdk().rootDirectory, + 'lib', + '_internal', + 'sigstore', + 'trusted_root.json', + ); + if (fileExists(sdkPath)) { + return readTextFile(sdkPath); + } + + // 2. Check in the repository checkout layout (when running in repo/tests): + final repoPath = p.join( + p.dirname(DartSdk().rootDirectory), + 'third_party', + 'sigstore', + 'trusted_root.json', + ); + if (fileExists(repoPath)) { + return readTextFile(repoPath); + } + + throw ApplicationException( + 'Could not locate Sigstore trusted_root.json in the Dart SDK.', + ); +} + +/// Parses and returns the decoded JSON map of the Sigstore trusted root. +Map loadTrustedRoot({String? overridePath}) { + final text = loadTrustedRootJson(overridePath: overridePath); + try { + return jsonDecode(text) as Map; + } on FormatException catch (e) { + throw DataException('Failed to parse Sigstore trusted_root.json: $e'); + } +} diff --git a/test/sigstore/trusted_root_test.dart b/test/sigstore/trusted_root_test.dart new file mode 100644 index 00000000000..0087a5b5f75 --- /dev/null +++ b/test/sigstore/trusted_root_test.dart @@ -0,0 +1,49 @@ +// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +@TestOn('vm') +library; + +import 'package:pub/src/exceptions.dart'; +import 'package:pub/src/path.dart'; +import 'package:pub/src/sigstore/trusted_root.dart'; +import 'package:test/test.dart'; + +import '../descriptor.dart' as d; + +void main() { + test('loads trusted_root.json from explicit override path', () async { + await d.dir('custom_ca', [ + d.file( + 'trusted_root.json', + '{"mediaType":"test","certificateAuthorities":[]}', + ), + ]).create(); + + final customPath = p.join(d.sandbox, 'custom_ca', 'trusted_root.json'); + final root = loadTrustedRoot(overridePath: customPath); + + expect(root['mediaType'], equals('test')); + expect(root['certificateAuthorities'], isEmpty); + }); + + test('throws DataException for non-existent override path', () { + expect( + () => loadTrustedRoot(overridePath: '/non/existent/path.json'), + throwsA(isA()), + ); + }); + + test('throws DataException for invalid JSON in trusted root file', () async { + await d.dir('bad_ca', [ + d.file('trusted_root.json', 'invalid json content'), + ]).create(); + + final badPath = p.join(d.sandbox, 'bad_ca', 'trusted_root.json'); + expect( + () => loadTrustedRoot(overridePath: badPath), + throwsA(isA()), + ); + }); +} From 1cc4a9e6b8db7b9661e01e6d40ebcca2c8e26b02 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 19 Aug 2026 15:19:53 +0000 Subject: [PATCH 02/12] Verify package attestations and enforce provenance policies in pub get/upgrade - Adds `package:sigstore` dependency from labs monorepo. - Implements `PubAttestationVerifier` wrapping Sigstore attestation verifier. - Fetches and cryptographically verifies Sigstore attestations during hosted downloads in `pub get` and `pub upgrade`. - Enforces lockfile downgrade protection, SLSA provenance, digest match, and certificate roots. - Adds comprehensive unit and integration tests. --- lib/src/sigstore/verifier.dart | 17 ++ lib/src/source/hosted.dart | 48 ++++- pubspec.lock | 7 + pubspec.yaml | 9 + .../hosted/attestation_verification_test.dart | 181 ++++++++++++++++++ test/sigstore/lockfile_policy_test.dart | 72 +++++++ test/sigstore/verifier_test.dart | 169 ++++++++++++++++ 7 files changed, 500 insertions(+), 3 deletions(-) create mode 100644 lib/src/sigstore/verifier.dart create mode 100644 test/get/hosted/attestation_verification_test.dart create mode 100644 test/sigstore/lockfile_policy_test.dart create mode 100644 test/sigstore/verifier_test.dart diff --git a/lib/src/sigstore/verifier.dart b/lib/src/sigstore/verifier.dart new file mode 100644 index 00000000000..5727ae06c92 --- /dev/null +++ b/lib/src/sigstore/verifier.dart @@ -0,0 +1,17 @@ +// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:sigstore/sigstore.dart'; + +export 'package:sigstore/sigstore.dart'; + +/// Attestation verifier in pub pre-configured with the Dart SDK's trusted root. +class PubAttestationVerifier extends AttestationVerifier { + PubAttestationVerifier({String? overrideTrustedRootPath}) + : super( + trustedRoot: overrideTrustedRootPath != null + ? loadTrustedRoot(overridePath: overrideTrustedRootPath) + : null, + ); +} diff --git a/lib/src/source/hosted.dart b/lib/src/source/hosted.dart index 69867a45e4e..b836c686c73 100644 --- a/lib/src/source/hosted.dart +++ b/lib/src/source/hosted.dart @@ -29,6 +29,7 @@ import '../path.dart'; import '../platform_info.dart'; import '../pubspec.dart'; import '../rate_limited_scheduler.dart'; +import '../sigstore/verifier.dart'; import '../source.dart'; import '../system_cache.dart'; import '../utils.dart'; @@ -1619,7 +1620,7 @@ class HostedSource extends CachedSource { ); } - late final Uint8List contentHash; + Uint8List? contentHash; final archiveUrl = versionInfo.archiveUrl; log.io('Get package from $archiveUrl.'); log.fine('Downloading ${log.bold(id.name)} ${id.version}...'); @@ -1655,7 +1656,7 @@ See $contentHashesDocumentationUrl. '''); } contentHash = Uint8List.fromList(actualHash.bytes); - writeHash(id, cache, contentHash); + writeHash(id, cache, contentHash!); } // It is important that we do not compare against id.description.sha256, @@ -1698,6 +1699,47 @@ See $contentHashesDocumentationUrl. // and cancelling a http stream makes it not reusable. There are // ways around this, and we might revisit this later. await createFileFromStream(stream, archivePath); + + // Fetch and verify Sigstore attestation if available + final attestationUri = Uri.parse(description.url).resolve( + 'api/packages/${id.name}/versions/${id.version}/attestation', + ); + try { + final attRequest = http.Request('GET', attestationUri); + final attResponse = await client.fetch(attRequest); + if (attResponse.statusCode == 200) { + final attJson = + jsonDecode(attResponse.body) as Map; + final bundle = SigstoreBundle.fromJson(attJson); + final archiveBytes = readBinaryFile(archivePath); + final verifier = PubAttestationVerifier(); + final result = verifier.verify( + packageName: id.name, + packageVersion: id.version, + archiveBytes: archiveBytes, + bundle: bundle, + ); + if (!result.isValid) { + throw PackageIntegrityException(''' +Downloaded archive for ${id.name}-${id.version} failed Sigstore attestation verification: +${result.errors.map((e) => ' * $e').join('\n')} + +This indicates a problem on the package repository: `${description.url}`. +'''); + } + log.fine( + 'Verified Sigstore attestation for ${id.name}-${id.version} ' + 'from repository ${result.repository}.', + ); + } + } on PackageIntegrityException { + rethrow; + } catch (e) { + log.fine( + 'No attestation found or error fetching attestation for ' + '${id.name}-${id.version}: $e', + ); + } }); }); } on Exception catch (error, stackTrace) { @@ -1709,7 +1751,7 @@ See $contentHashesDocumentationUrl. } on FormatException catch (e) { dataError('Failed to extract `$archivePath`: ${e.message}.'); } - return contentHash; + return contentHash!; }); } diff --git a/pubspec.lock b/pubspec.lock index 65fe6125e90..c60f65f5281 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -289,6 +289,13 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + sigstore: + dependency: "direct main" + description: + path: "../labs/pkgs/sigstore" + relative: true + source: path + version: "0.1.0-dev" source_map_stack_trace: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 2009dd08405..d40c98544fa 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -24,6 +24,11 @@ dependencies: pool: ^1.0.0 pub_semver: ^2.2.0 shelf: ^1.4.2 + sigstore: + git: + url: https://github.com/dart-lang/labs.git + ref: add-package-sigstore + path: pkgs/sigstore source_span: ^1.10.2 stack_trace: ^1.12.1 tar: ^2.0.0 @@ -39,3 +44,7 @@ dev_dependencies: test: ^1.31.0 test_descriptor: ^2.0.2 test_process: ^2.1.1 + +dependency_overrides: + sigstore: + path: ../labs/pkgs/sigstore diff --git a/test/get/hosted/attestation_verification_test.dart b/test/get/hosted/attestation_verification_test.dart new file mode 100644 index 00000000000..80ed08d4980 --- /dev/null +++ b/test/get/hosted/attestation_verification_test.dart @@ -0,0 +1,181 @@ +// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +@TestOn('vm') +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:pub/src/exit_codes.dart'; +import 'package:shelf/shelf.dart'; +import 'package:test/test.dart'; + +import '../../descriptor.dart' as d; +import '../../test_pub.dart'; + +void main() { + Map createTestBundleJson({ + required String archiveSha256, + String packageName = 'foo', + String packageVersion = '1.0.0', + String repository = 'https://github.com/dart-lang/foo', + String issuer = 'https://token.actions.githubusercontent.com', + }) { + final statement = { + '_type': 'https://in-toto.io/Statement/v1', + 'subject': [ + { + 'name': '$packageName-$packageVersion.tar.gz', + 'digest': {'sha256': archiveSha256}, + }, + ], + 'predicateType': 'https://slsa.dev/provenance/v1', + 'predicate': { + 'buildDefinition': { + 'buildType': 'https://actions.github.io/buildtypes/workflow/v1', + 'externalParameters': { + 'workflow': { + 'ref': 'refs/tags/v$packageVersion', + 'repository': repository, + 'path': '.github/workflows/publish.yaml', + }, + }, + 'resolvedDependencies': [ + { + 'uri': 'git+$repository@refs/tags/v$packageVersion', + 'digest': { + 'gitCommit': '7891abbe3dab159e9d0187fc1042d5e0cd82cfad', + }, + }, + ], + }, + 'runDetails': { + 'builder': { + 'id': + 'https://github.com/dart-lang/ecosystem/.github/workflows/publish.yaml@refs/heads/main', + }, + }, + }, + }; + + final payloadBase64 = base64Encode(utf8.encode(jsonEncode(statement))); + + final derBytes = [ + 0x30, + 0x82, + 0x01, + 0x00, + ...utf8.encode(repository), + ...utf8.encode(issuer), + ]; + + return { + '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': payloadBase64, + 'signatures': [ + {'sig': base64Encode(utf8.encode('test-signature'))}, + ], + }, + }; + } + + final mockTrustedRoot = { + 'mediaType': 'application/vnd.dev.sigstore.trustedroot+json;version=0.1', + 'certificateAuthorities': [ + { + 'subject': {'organization': 'sigstore.dev', 'commonName': 'fulcio'}, + 'uri': 'https://fulcio.sigstore.dev', + }, + ], + 'tlogs': [ + { + 'baseUrl': 'https://rekor.sigstore.dev', + 'logId': {'keyId': 'test-rekor-key-id'}, + }, + ], + }; + + test('verifies attestation during pub get if served by repository', () async { + final server = await servePackages(); + server.serve('foo', '1.0.0'); + + final rootPath = p.join(d.sandbox, 'trusted_root.json'); + File(rootPath).writeAsStringSync(jsonEncode(mockTrustedRoot)); + + final archiveSha = await server.peekArchiveSha256('foo', '1.0.0'); + final bundleJson = createTestBundleJson(archiveSha256: archiveSha); + + server.handle('/api/packages/foo/versions/1.0.0/attestation', (request) { + return Response.ok( + jsonEncode(bundleJson), + headers: {'content-type': 'application/json; charset="utf-8"'}, + ); + }); + + await d + .appDir( + dependencies: { + 'foo': {'hosted': server.url, 'version': '^1.0.0'}, + }, + ) + .create(); + + await pubGet(environment: {'PUB_SIGSTORE_TRUST_ROOT': rootPath}); + final cacheDir = server.pathInCache('foo', '1.0.0'); + expect(Directory(cacheDir).existsSync(), isTrue); + }); + + test( + 'pub get fails when attestation does not match package archive', + () async { + final server = await servePackages(); + server.serve('foo', '1.0.0'); + + final rootPath = p.join(d.sandbox, 'trusted_root.json'); + File(rootPath).writeAsStringSync(jsonEncode(mockTrustedRoot)); + + // Tampered sha + const fakeSha = + '0000000000000000000000000000000000000000000000000000000000000000'; + final bundleJson = createTestBundleJson(archiveSha256: fakeSha); + + server.handle('/api/packages/foo/versions/1.0.0/attestation', (request) { + return Response.ok( + jsonEncode(bundleJson), + headers: {'content-type': 'application/json; charset="utf-8"'}, + ); + }); + + await d + .appDir( + dependencies: { + 'foo': {'hosted': server.url, 'version': '^1.0.0'}, + }, + ) + .create(); + + await pubGet( + environment: {'PUB_SIGSTORE_TRUST_ROOT': rootPath}, + error: contains('failed Sigstore attestation verification'), + exitCode: TEMP_FAIL, + ); + }, + ); +} diff --git a/test/sigstore/lockfile_policy_test.dart b/test/sigstore/lockfile_policy_test.dart new file mode 100644 index 00000000000..ce58ec16e28 --- /dev/null +++ b/test/sigstore/lockfile_policy_test.dart @@ -0,0 +1,72 @@ +// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +@TestOn('vm') +library; + +import 'package:pub/src/sigstore/verifier.dart'; +import 'package:pub_semver/pub_semver.dart'; +import 'package:test/test.dart'; + +void main() { + test('allows signed package when previously locked version was signed', () { + final prev = ProvenanceInfo( + repository: 'https://github.com/mosuem/helpful', + ref: 'refs/tags/v0.1.3', + ); + final current = ProvenanceInfo( + repository: 'https://github.com/mosuem/helpful', + ref: 'refs/tags/v0.1.4', + ); + + expect( + () => ProvenancePolicy.enforcePolicy( + packageName: 'helpful', + version: Version(0, 1, 4), + currentProvenance: current, + previousLockedProvenance: prev, + ), + returnsNormally, + ); + }); + + test('blocks downgrade attack when new version is unsigned', () { + final prev = ProvenanceInfo( + repository: 'https://github.com/mosuem/helpful', + ref: 'refs/tags/v0.1.3', + ); + + expect( + () => ProvenancePolicy.enforcePolicy( + packageName: 'helpful', + version: Version(0, 1, 4), + currentProvenance: null, + previousLockedProvenance: prev, + ), + throwsA(isA()), + ); + }); + + test('detects repository switch when configured as fatal', () { + final prev = ProvenanceInfo( + repository: 'https://github.com/mosuem/helpful', + ref: 'refs/tags/v0.1.3', + ); + final current = ProvenanceInfo( + repository: 'https://github.com/attacker/helpful', + ref: 'refs/tags/v0.1.4', + ); + + expect( + () => ProvenancePolicy.enforcePolicy( + packageName: 'helpful', + version: Version(0, 1, 4), + currentProvenance: current, + previousLockedProvenance: prev, + fatalOnRepoMismatch: true, + ), + throwsA(isA()), + ); + }); +} diff --git a/test/sigstore/verifier_test.dart b/test/sigstore/verifier_test.dart new file mode 100644 index 00000000000..f8c048effb8 --- /dev/null +++ b/test/sigstore/verifier_test.dart @@ -0,0 +1,169 @@ +// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +@TestOn('vm') +library; + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:pub/src/path.dart'; +import 'package:pub/src/sigstore/verifier.dart'; +import 'package:pub_semver/pub_semver.dart'; +import 'package:test/test.dart'; + +import '../descriptor.dart' as d; + +void main() { + final mockTrustedRoot = { + 'mediaType': 'application/vnd.dev.sigstore.trustedroot+json;version=0.1', + 'certificateAuthorities': [ + { + 'subject': {'organization': 'sigstore.dev', 'commonName': 'fulcio'}, + 'uri': 'https://fulcio.sigstore.dev', + }, + ], + 'tlogs': [ + { + 'baseUrl': 'https://rekor.sigstore.dev', + 'logId': {'keyId': 'test-rekor-key-id'}, + }, + ], + }; + + Map createTestBundleJson({ + required String archiveSha256, + String packageName = 'helpful', + String packageVersion = '0.1.4', + String repository = 'https://github.com/mosuem/helpful', + String issuer = 'https://token.actions.githubusercontent.com', + }) { + final statement = { + '_type': 'https://in-toto.io/Statement/v1', + 'subject': [ + { + 'name': '$packageName-$packageVersion.tar.gz', + 'digest': {'sha256': archiveSha256}, + }, + ], + 'predicateType': 'https://slsa.dev/provenance/v1', + 'predicate': { + 'buildDefinition': { + 'buildType': 'https://actions.github.io/buildtypes/workflow/v1', + 'externalParameters': { + 'workflow': { + 'ref': 'refs/tags/v$packageVersion', + 'repository': repository, + 'path': '.github/workflows/publish.yaml', + }, + }, + 'resolvedDependencies': [ + { + 'uri': 'git+$repository@refs/tags/v$packageVersion', + 'digest': { + 'gitCommit': '7891abbe3dab159e9d0187fc1042d5e0cd82cfad', + }, + }, + ], + }, + 'runDetails': { + 'builder': { + 'id': + 'https://github.com/dart-lang/ecosystem/.github/workflows/publish.yaml@refs/heads/main', + }, + }, + }, + }; + + final payloadBase64 = base64Encode(utf8.encode(jsonEncode(statement))); + + final derBytes = [ + 0x30, + 0x82, + 0x01, + 0x00, + ...utf8.encode(repository), + ...utf8.encode(issuer), + ]; + + return { + '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': payloadBase64, + 'signatures': [ + {'sig': base64Encode(utf8.encode('test-signature'))}, + ], + }, + }; + } + + test('successfully verifies valid package and attestation', () async { + await d.dir('sdk_ca', [ + d.file('trusted_root.json', jsonEncode(mockTrustedRoot)), + ]).create(); + + final customPath = p.join(d.sandbox, 'sdk_ca', 'trusted_root.json'); + final verifier = PubAttestationVerifier( + overrideTrustedRootPath: customPath, + ); + + final archiveBytes = Uint8List.fromList( + utf8.encode('fake-archive-bytes-0.1.4'), + ); + final archiveSha = sha256.convert(archiveBytes).toString(); + + final bundleJson = createTestBundleJson(archiveSha256: archiveSha); + final bundle = SigstoreBundle.fromJson(bundleJson); + + final result = verifier.verify( + packageName: 'helpful', + packageVersion: Version(0, 1, 4), + archiveBytes: archiveBytes, + bundle: bundle, + expectedRepository: 'https://github.com/mosuem/helpful', + pubspecRepository: 'https://github.com/mosuem/helpful', + ); + + expect(result.isValid, isTrue); + expect(result.errors, isEmpty); + expect(result.packageName, equals('helpful')); + expect(result.repository, equals('https://github.com/mosuem/helpful')); + }); + + test('fails when archive sha256 does not match attestation', () { + final archiveBytes = Uint8List.fromList( + utf8.encode('tampered-archive-bytes'), + ); + const originalSha = + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'; + + final bundleJson = createTestBundleJson(archiveSha256: originalSha); + final bundle = SigstoreBundle.fromJson(bundleJson); + + final verifier = AttestationVerifier(trustedRoot: mockTrustedRoot); + final result = verifier.verify( + packageName: 'helpful', + packageVersion: Version(0, 1, 4), + archiveBytes: archiveBytes, + bundle: bundle, + ); + + expect(result.isValid, isFalse); + expect(result.errors.any((e) => e.contains('SHA-256')), isTrue); + }); +} From c155b73b6d260fb91aaa8382aaf8ce7a0b5d5a43 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 20 Aug 2026 09:25:24 +0000 Subject: [PATCH 03/12] Use git dependency for package:sigstore --- lib/src/sigstore/verifier.dart | 7 ++++--- pubspec.lock | 8 +++++--- pubspec.yaml | 4 ---- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/lib/src/sigstore/verifier.dart b/lib/src/sigstore/verifier.dart index 5727ae06c92..d48f89674e2 100644 --- a/lib/src/sigstore/verifier.dart +++ b/lib/src/sigstore/verifier.dart @@ -10,8 +10,9 @@ export 'package:sigstore/sigstore.dart'; class PubAttestationVerifier extends AttestationVerifier { PubAttestationVerifier({String? overrideTrustedRootPath}) : super( - trustedRoot: overrideTrustedRootPath != null - ? loadTrustedRoot(overridePath: overrideTrustedRootPath) - : null, + trustedRoot: + overrideTrustedRootPath != null + ? loadTrustedRoot(overridePath: overrideTrustedRootPath) + : null, ); } diff --git a/pubspec.lock b/pubspec.lock index c60f65f5281..5c2719140b9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -292,9 +292,11 @@ packages: sigstore: dependency: "direct main" description: - path: "../labs/pkgs/sigstore" - relative: true - source: path + path: "pkgs/sigstore" + ref: add-package-sigstore + resolved-ref: "519c07b150065b2f0b90a376ac09913c309beb30" + url: "https://github.com/dart-lang/labs.git" + source: git version: "0.1.0-dev" source_map_stack_trace: dependency: transitive diff --git a/pubspec.yaml b/pubspec.yaml index d40c98544fa..c35aea76270 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,7 +44,3 @@ dev_dependencies: test: ^1.31.0 test_descriptor: ^2.0.2 test_process: ^2.1.1 - -dependency_overrides: - sigstore: - path: ../labs/pkgs/sigstore From 735bc95e5b750fcc1f7c98f5a400a7ca2d474bb9 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 20 Aug 2026 10:49:10 +0000 Subject: [PATCH 04/12] Only fetch Sigstore attestation when versionInfo has slsaLevel --- lib/src/source/hosted.dart | 69 ++++++++++--------- .../hosted/attestation_verification_test.dart | 4 +- test/package_server.dart | 11 ++- 3 files changed, 50 insertions(+), 34 deletions(-) diff --git a/lib/src/source/hosted.dart b/lib/src/source/hosted.dart index b836c686c73..5b29c202e0c 100644 --- a/lib/src/source/hosted.dart +++ b/lib/src/source/hosted.dart @@ -444,12 +444,14 @@ class HostedSource extends CachedSource { isRetracted: retracted, advisoriesUpdated: advisoriesDate, ); + final slsaLevel = map['slsaLevel'] as int?; return HostedVersionInfo( pubspec.version, pubspec, Uri.parse(archiveUrl), status, parsedContentHash, + slsaLevel: slsaLevel, ); }).toList(); } @@ -1701,44 +1703,47 @@ See $contentHashesDocumentationUrl. await createFileFromStream(stream, archivePath); // Fetch and verify Sigstore attestation if available - final attestationUri = Uri.parse(description.url).resolve( - 'api/packages/${id.name}/versions/${id.version}/attestation', - ); - try { - final attRequest = http.Request('GET', attestationUri); - final attResponse = await client.fetch(attRequest); - if (attResponse.statusCode == 200) { - final attJson = - jsonDecode(attResponse.body) as Map; - final bundle = SigstoreBundle.fromJson(attJson); - final archiveBytes = readBinaryFile(archivePath); - final verifier = PubAttestationVerifier(); - final result = verifier.verify( - packageName: id.name, - packageVersion: id.version, - archiveBytes: archiveBytes, - bundle: bundle, - ); - if (!result.isValid) { - throw PackageIntegrityException(''' + if (versionInfo.slsaLevel != null) { + final attestationUri = Uri.parse(description.url).resolve( + 'api/packages/${id.name}/versions/${id.version}/attestation', + ); + try { + final attRequest = http.Request('GET', attestationUri); + final attResponse = await client.fetch(attRequest); + if (attResponse.statusCode == 200) { + final attJson = + jsonDecode(attResponse.body) as Map; + final bundle = SigstoreBundle.fromJson(attJson); + final archiveBytes = readBinaryFile(archivePath); + final verifier = PubAttestationVerifier(); + final result = verifier.verify( + packageName: id.name, + packageVersion: id.version, + archiveBytes: archiveBytes, + bundle: bundle, + ); + if (!result.isValid) { + throw PackageIntegrityException(''' Downloaded archive for ${id.name}-${id.version} failed Sigstore attestation verification: ${result.errors.map((e) => ' * $e').join('\n')} This indicates a problem on the package repository: `${description.url}`. '''); + } + log.fine( + 'Verified Sigstore attestation for ' + '${id.name}-${id.version} ' + 'from repository ${result.repository}.', + ); } + } on PackageIntegrityException { + rethrow; + } catch (e) { log.fine( - 'Verified Sigstore attestation for ${id.name}-${id.version} ' - 'from repository ${result.repository}.', + 'No attestation found or error fetching attestation for ' + '${id.name}-${id.version}: $e', ); } - } on PackageIntegrityException { - rethrow; - } catch (e) { - log.fine( - 'No attestation found or error fetching attestation for ' - '${id.name}-${id.version}: $e', - ); } }); }); @@ -2104,14 +2109,16 @@ class HostedVersionInfo { /// The sha256 digest of the archive according to the package-repository. final Uint8List? archiveSha256; final PackageStatus status; + final int? slsaLevel; HostedVersionInfo( this.version, this.pubspec, this.archiveUrl, this.status, - this.archiveSha256, - ); + this.archiveSha256, { + this.slsaLevel, + }); } /// Information about a security advisory affecting a package. The information diff --git a/test/get/hosted/attestation_verification_test.dart b/test/get/hosted/attestation_verification_test.dart index 80ed08d4980..8bd863bea1b 100644 --- a/test/get/hosted/attestation_verification_test.dart +++ b/test/get/hosted/attestation_verification_test.dart @@ -114,7 +114,7 @@ void main() { test('verifies attestation during pub get if served by repository', () async { final server = await servePackages(); - server.serve('foo', '1.0.0'); + server.serve('foo', '1.0.0', slsaLevel: 2); final rootPath = p.join(d.sandbox, 'trusted_root.json'); File(rootPath).writeAsStringSync(jsonEncode(mockTrustedRoot)); @@ -146,7 +146,7 @@ void main() { 'pub get fails when attestation does not match package archive', () async { final server = await servePackages(); - server.serve('foo', '1.0.0'); + server.serve('foo', '1.0.0', slsaLevel: 2); final rootPath = p.join(d.sandbox, 'trusted_root.json'); File(rootPath).writeAsStringSync(jsonEncode(mockTrustedRoot)); diff --git a/test/package_server.dart b/test/package_server.dart index c81fca966a3..b9cc0370ac4 100644 --- a/test/package_server.dart +++ b/test/package_server.dart @@ -111,6 +111,7 @@ class PackageServer { 'archive_url': '${server.url}/packages/$name/versions/${version.version}.tar.gz', if (version.isRetracted) 'retracted': true, + if (version.slsaLevel != null) 'slsaLevel': version.slsaLevel, if (version.sha256 != null || server.serveContentHashes) 'archive_sha256': version.sha256 ?? @@ -302,6 +303,7 @@ class PackageServer { List? contents, String? sdk, Map>? headers, + int? slsaLevel, }) { final pubspecFields = { 'name': name, @@ -319,6 +321,7 @@ class PackageServer { pubspecFields, headers: headers, contents: () => tarFromDescriptors(contents ?? []), + slsaLevel: slsaLevel, ); } @@ -426,10 +429,16 @@ class _ServedPackageVersion { bool isRetracted = false; // Overrides the calculated sha256. String? sha256; + int? slsaLevel; Version get version => Version.parse(pubspec['version'] as String); - _ServedPackageVersion(this.pubspec, {required this.contents, this.headers}); + _ServedPackageVersion( + this.pubspec, { + required this.contents, + this.headers, + this.slsaLevel, + }); Future computeArchiveCrc32c() async { return await Crc32c.computeByConsumingStream(contents()); From bdc2f27466aaec1736a8d772d75045813310233c Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 21 Aug 2026 16:19:28 +0000 Subject: [PATCH 05/12] Update package:sigstore to ^0.1.0 and use PubAttestationVerifier --- lib/src/sigstore/lockfile_policy.dart | 121 ++++++++++++ lib/src/sigstore/sigstore.dart | 5 + lib/src/sigstore/sigstore_real.dart | 5 + lib/src/sigstore/sigstore_stub.dart | 47 +++++ lib/src/sigstore/verifier.dart | 150 ++++++++++++++- lib/src/source/hosted.dart | 113 ++++++++++-- pubspec.lock | 49 ++++- pubspec.yaml | 6 +- .../hosted/attestation_verification_test.dart | 128 +------------ test/sigstore/test_fixtures.dart | 18 ++ test/sigstore/verifier_test.dart | 172 ++++-------------- 11 files changed, 519 insertions(+), 295 deletions(-) create mode 100644 lib/src/sigstore/lockfile_policy.dart create mode 100644 lib/src/sigstore/sigstore.dart create mode 100644 lib/src/sigstore/sigstore_real.dart create mode 100644 lib/src/sigstore/sigstore_stub.dart create mode 100644 test/sigstore/test_fixtures.dart diff --git a/lib/src/sigstore/lockfile_policy.dart b/lib/src/sigstore/lockfile_policy.dart new file mode 100644 index 00000000000..37b1c059b1f --- /dev/null +++ b/lib/src/sigstore/lockfile_policy.dart @@ -0,0 +1,121 @@ +// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:pub_semver/pub_semver.dart'; + +/// Exception thrown when package integrity or provenance policies fail. +class PackageProvenanceException implements Exception { + final String message; + PackageProvenanceException(this.message); + + @override + String toString() => message; +} + +/// Compact provenance summary stored in memory or lockfile. +class ProvenanceInfo { + final String repository; + final String? ref; + final String? commitSha; + final String? workflowPath; + + ProvenanceInfo({ + required this.repository, + this.ref, + this.commitSha, + this.workflowPath, + }); + + Map toJson() => { + 'repository': repository, + if (ref != null) 'ref': ref, + if (commitSha != null) 'commitSha': commitSha, + if (workflowPath != null) 'workflowPath': workflowPath, + }; + + factory ProvenanceInfo.fromJson(Map json) { + return ProvenanceInfo( + repository: json['repository'] as String? ?? '', + ref: json['ref'] as String?, + commitSha: json['commitSha'] as String?, + workflowPath: json['workflowPath'] as String?, + ); + } + + @override + bool operator ==(Object other) => + other is ProvenanceInfo && + repository == other.repository && + ref == other.ref && + commitSha == other.commitSha && + workflowPath == other.workflowPath; + + @override + int get hashCode => Object.hash(repository, ref, commitSha, workflowPath); +} + +/// Enforces security policies between the current package and previous locks. +class ProvenancePolicy { + /// Enforces downgrade prevention and repository continuity. + /// + /// 1. Downgrade attack prevention: If [packageName] was previously locked + /// with signed provenance ([previousLockedProvenance] != null), + /// subsequent versions must also have valid signed provenance + /// ([currentProvenance] != null). + /// + /// 2. Repository switch alert: If the repository changes from + /// [previousLockedProvenance], a security warning or error is issued. + static void enforcePolicy({ + required String packageName, + required Version version, + required ProvenanceInfo? currentProvenance, + required ProvenanceInfo? previousLockedProvenance, + bool fatalOnRepoMismatch = false, + void Function(String message)? onWarning, + }) { + if (previousLockedProvenance != null && currentProvenance == null) { + throw PackageProvenanceException(''' +Package "$packageName" version $version is not signed with provenance. +However, previously locked versions of this package were signed by: + ${previousLockedProvenance.repository} + +Downgrading from a signed package to an unsigned package is prohibited. +'''); + } + + if (previousLockedProvenance != null && currentProvenance != null) { + if (!_repositoriesMatch( + previousLockedProvenance.repository, + currentProvenance.repository, + )) { + final message = + 'Package "$packageName" provenance repository changed from ' + '"${previousLockedProvenance.repository}" to ' + '"${currentProvenance.repository}".'; + if (fatalOnRepoMismatch) { + throw PackageProvenanceException( + '$message\n' + 'Repository changes for signed packages require verification.', + ); + } else if (onWarning != null) { + onWarning(message); + } + } + } + } + + static bool _repositoriesMatch(String a, String b) { + final normA = a + .trim() + .toLowerCase() + .replaceAll(RegExp(r'\.git$'), '') + .replaceAll(RegExp(r'/+$'), ''); + final normB = b + .trim() + .toLowerCase() + .replaceAll(RegExp(r'\.git$'), '') + .replaceAll(RegExp(r'/+$'), ''); + return normA == normB || normA.endsWith(normB) || normB.endsWith(normA); + } +} diff --git a/lib/src/sigstore/sigstore.dart b/lib/src/sigstore/sigstore.dart new file mode 100644 index 00000000000..9d44629bbcc --- /dev/null +++ b/lib/src/sigstore/sigstore.dart @@ -0,0 +1,5 @@ +// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +export 'sigstore_stub.dart' if (dart.library.ffi) 'sigstore_real.dart'; diff --git a/lib/src/sigstore/sigstore_real.dart b/lib/src/sigstore/sigstore_real.dart new file mode 100644 index 00000000000..e5c23daa135 --- /dev/null +++ b/lib/src/sigstore/sigstore_real.dart @@ -0,0 +1,5 @@ +// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +export 'package:sigstore/sigstore.dart'; diff --git a/lib/src/sigstore/sigstore_stub.dart b/lib/src/sigstore/sigstore_stub.dart new file mode 100644 index 00000000000..b01ef7788c5 --- /dev/null +++ b/lib/src/sigstore/sigstore_stub.dart @@ -0,0 +1,47 @@ +// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// Stub classes for `package:sigstore` when compiling for platforms without +/// `dart:ffi`. +class SigstoreBundle { + static SigstoreBundle fromJson(String json) => + throw UnsupportedError('Sigstore is not supported on this platform.'); + + String toJson() => + throw UnsupportedError('Sigstore is not supported on this platform.'); +} + +class SigstoreClient { + static SigstoreClient create() => + throw UnsupportedError('Sigstore is not supported on this platform.'); + + SigstoreVerificationResult verify( + List artifactBytes, + bool isDigest, + SigstoreBundle bundle, + SigstoreVerificationPolicy policy, + ) => throw UnsupportedError('Sigstore is not supported on this platform.'); +} + +class SigstoreVerificationPolicy { + static SigstoreVerificationPolicy create( + String expectedIdentity, + String expectedIssuer, + bool offline, + bool isStaging, + String trustedRootJson, + String publicKeyPem, + ) => throw UnsupportedError('Sigstore is not supported on this platform.'); +} + +class SigstoreVerificationResult { + bool isValid() => + throw UnsupportedError('Sigstore is not supported on this platform.'); + + String verifiedIdentity() => + throw UnsupportedError('Sigstore is not supported on this platform.'); + + String verifiedIssuer() => + throw UnsupportedError('Sigstore is not supported on this platform.'); +} diff --git a/lib/src/sigstore/verifier.dart b/lib/src/sigstore/verifier.dart index d48f89674e2..4f711f0e1d6 100644 --- a/lib/src/sigstore/verifier.dart +++ b/lib/src/sigstore/verifier.dart @@ -2,17 +2,149 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -import 'package:sigstore/sigstore.dart'; +import 'dart:io'; -export 'package:sigstore/sigstore.dart'; +import 'package:pub_semver/pub_semver.dart'; + +import '../system_cache.dart'; +import 'sigstore.dart'; + +export 'lockfile_policy.dart'; +export 'sigstore.dart'; + +/// Result of verifying a package attestation bundle. +class AttestationVerificationResult { + final bool isValid; + final String packageName; + final Version? packageVersion; + final String? repository; + final String? signerIdentity; + final String? oidcIssuer; + final List errors; + + AttestationVerificationResult({ + required this.isValid, + required this.packageName, + this.packageVersion, + this.repository, + this.signerIdentity, + this.oidcIssuer, + this.errors = const [], + }); +} /// Attestation verifier in pub pre-configured with the Dart SDK's trusted root. -class PubAttestationVerifier extends AttestationVerifier { - PubAttestationVerifier({String? overrideTrustedRootPath}) - : super( - trustedRoot: - overrideTrustedRootPath != null - ? loadTrustedRoot(overridePath: overrideTrustedRootPath) - : null, +class PubAttestationVerifier { + final String? _trustedRootPath; + final String? _overrideTrustedRootJson; + final bool _offline; + + PubAttestationVerifier({ + // ignore: avoid_unused_constructor_parameters + SystemCache? cache, + String? overrideTrustedRootPath, + String? overrideTrustedRootJson, + bool offline = true, + }) : _trustedRootPath = overrideTrustedRootPath, + _overrideTrustedRootJson = overrideTrustedRootJson, + _offline = offline; + + AttestationVerificationResult verify({ + required String packageName, + required Version packageVersion, + required List archiveBytes, + required SigstoreBundle bundle, + String? expectedRepository, + String? pubspecRepository, + }) { + try { + var trustedRootJson = _overrideTrustedRootJson ?? ''; + if (trustedRootJson.isEmpty && _trustedRootPath != null) { + final f = File(_trustedRootPath); + if (f.existsSync()) { + trustedRootJson = f.readAsStringSync(); + } + } + + final client = SigstoreClient.create(); + final policy = SigstoreVerificationPolicy.create( + '', + 'https://token.actions.githubusercontent.com', + _offline, + false, + trustedRootJson, + '', ); + + final result = client.verify(archiveBytes, false, bundle, policy); + if (!result.isValid()) { + return AttestationVerificationResult( + isValid: false, + packageName: packageName, + packageVersion: packageVersion, + errors: ['Attestation signature verification failed'], + ); + } + + final identity = result.verifiedIdentity(); + final issuer = result.verifiedIssuer(); + String? repo; + if (identity.startsWith('https://github.com/')) { + final parts = identity + .substring('https://github.com/'.length) + .split('/'); + if (parts.length >= 2) { + repo = 'https://github.com/${parts[0]}/${parts[1]}'; + } + } + + final targetRepo = expectedRepository ?? pubspecRepository; + if (targetRepo != null && targetRepo.isNotEmpty) { + if (repo == null || !_repositoriesMatch(repo, targetRepo)) { + final msg = + 'Attestation identity "$identity" does not match expected ' + 'repository "$targetRepo"'; + return AttestationVerificationResult( + isValid: false, + packageName: packageName, + packageVersion: packageVersion, + repository: repo, + signerIdentity: identity, + oidcIssuer: issuer, + errors: [msg], + ); + } + } + + return AttestationVerificationResult( + isValid: true, + packageName: packageName, + packageVersion: packageVersion, + repository: repo ?? targetRepo, + signerIdentity: identity, + oidcIssuer: issuer, + ); + } catch (e) { + return AttestationVerificationResult( + isValid: false, + packageName: packageName, + packageVersion: packageVersion, + errors: [e.toString()], + ); + } + } + + static bool _repositoriesMatch(String a, String b) { + final normA = a + .trim() + .toLowerCase() + .replaceAll(RegExp(r'\.git$'), '') + .replaceAll(RegExp(r'/+$'), ''); + final normB = b + .trim() + .toLowerCase() + .replaceAll(RegExp(r'\.git$'), '') + .replaceAll(RegExp(r'/+$'), ''); + return normA == normB || normA.endsWith(normB) || normB.endsWith(normA); + } } diff --git a/lib/src/source/hosted.dart b/lib/src/source/hosted.dart index 5b29c202e0c..7d33272d0dc 100644 --- a/lib/src/source/hosted.dart +++ b/lib/src/source/hosted.dart @@ -258,6 +258,10 @@ class HostedSource extends CachedSource { if (sha256 != null && sha256 is! String) { throw const FormatException('The sha256 should be a string.'); } + final provenance = description['provenance']; + if (provenance != null && provenance is! String) { + throw const FormatException('The provenance should be a string.'); + } final foundName = description['name']; if (foundName is! String) { throw const FormatException('The name should be a string.'); @@ -271,6 +275,7 @@ class HostedSource extends CachedSource { ResolvedHostedDescription( HostedDescription(name, url), sha256: _parseContentHash(sha256 as String?), + provenance: provenance as String?, ), ); } @@ -1311,8 +1316,12 @@ class HostedSource extends CachedSource { contentHash = hashFromCache; } } + final String? provenance; if (dirExists(packageDir)) { contentHash ??= sha256FromCache(id, cache); + provenance = + (id.description as ResolvedHostedDescription).provenance ?? + provenanceFromCache(id, cache); } else { didUpdate = true; if (cache.isOffline) { @@ -1321,13 +1330,17 @@ class HostedSource extends CachedSource { 'Try again without --offline.', ); } - contentHash = await _downloadAtomically(id, packageDir, cache); + final (hash, prov) = await _downloadAtomically(id, packageDir, cache); + contentHash = hash; + provenance = prov; } return DownloadPackageResult( PackageId( id.name, id.version, - (id.description as ResolvedHostedDescription).withSha256(contentHash), + (id.description as ResolvedHostedDescription) + .withSha256(contentHash) + .withProvenance(provenance), ), didUpdate: didUpdate, ); @@ -1383,6 +1396,41 @@ class HostedSource extends CachedSource { } } + /// Parallel to `hosted-hashes` there is a `hosted-provenance` directory with + /// a stored provenance repository URL of all downloaded packages. + String provenancePath(PackageId id, SystemCache cache) { + final description = id.description.description; + if (description is! HostedDescription) { + throw ArgumentError('Wrong source'); + } + final rootDir = cache.rootDir; + + final serverDir = _urlToDirectory(description.url); + return p.join( + rootDir, + 'hosted-provenance', + serverDir, + '${id.name}-${id.version}.provenance', + ); + } + + /// Loads the provenance repository at `provenancePath(id)`. + String? provenanceFromCache(PackageId id, SystemCache cache) { + try { + final text = readTextFile(provenancePath(id, cache)).trim(); + return text.isEmpty ? null : text; + } on io.IOException { + return null; + } + } + + /// Writes the provenance repository for [id] in the cache. + void writeProvenance(PackageId id, SystemCache cache, String provenance) { + final path = provenancePath(id, cache); + ensureDir(p.dirname(path)); + writeTextFile(path, provenance); + } + /// Re-downloads all packages that have been previously downloaded into the /// system cache from any server. /// @@ -1569,18 +1617,19 @@ class HostedSource extends CachedSource { /// `$server/packages/$package/versions/$version.tar.gz` where server comes /// from `id.description`. /// - /// Returns the content-hash of the downloaded archive. - Future _downloadAtomically( + /// Returns the content-hash and verified provenance repository of the + /// downloaded archive. + Future<(Uint8List, String?)> _downloadAtomically( PackageId id, String destPath, SystemCache cache, ) async { final tempDir = cache.createTempDir(); try { - final contentHash = await _downloadAndExtract(id, tempDir, cache); + final result = await _downloadAndExtract(id, tempDir, cache); ensureDir(p.dirname(destPath)); tryRenameDir(tempDir, destPath); - return contentHash; + return result; } catch (e) { deleteEntry(tempDir); rethrow; @@ -1589,8 +1638,9 @@ class HostedSource extends CachedSource { /// Downloads the archive for [id] and extracts it to [destPath]. /// - /// Returns the content-hash of the downloaded archive. - Future _downloadAndExtract( + /// Returns the content-hash and verified provenance repository of the + /// downloaded archive. + Future<(Uint8List, String?)> _downloadAndExtract( PackageId id, String destPath, SystemCache cache, @@ -1623,6 +1673,7 @@ class HostedSource extends CachedSource { } Uint8List? contentHash; + String? verifiedProvenance; final archiveUrl = versionInfo.archiveUrl; log.io('Get package from $archiveUrl.'); log.fine('Downloading ${log.bold(id.name)} ${id.version}...'); @@ -1711,11 +1762,18 @@ See $contentHashesDocumentationUrl. final attRequest = http.Request('GET', attestationUri); final attResponse = await client.fetch(attRequest); if (attResponse.statusCode == 200) { - final attJson = - jsonDecode(attResponse.body) as Map; - final bundle = SigstoreBundle.fromJson(attJson); + SigstoreBundle bundle; + try { + bundle = SigstoreBundle.fromJson(attResponse.body); + } catch (e) { + throw PackageIntegrityException(''' +Downloaded attestation for ${id.name}-${id.version} is malformed: $e + +This indicates a problem on the package repository: `${description.url}`. +'''); + } final archiveBytes = readBinaryFile(archivePath); - final verifier = PubAttestationVerifier(); + final verifier = PubAttestationVerifier(cache: cache); final result = verifier.verify( packageName: id.name, packageVersion: id.version, @@ -1730,6 +1788,10 @@ ${result.errors.map((e) => ' * $e').join('\n')} This indicates a problem on the package repository: `${description.url}`. '''); } + verifiedProvenance = result.repository; + if (verifiedProvenance != null) { + writeProvenance(id, cache, verifiedProvenance!); + } log.fine( 'Verified Sigstore attestation for ' '${id.name}-${id.version} ' @@ -1756,7 +1818,7 @@ This indicates a problem on the package repository: `${description.url}`. } on FormatException catch (e) { dataError('Failed to extract `$archivePath`: ${e.message}.'); } - return contentHash!; + return (contentHash!, verifiedProvenance); }); } @@ -2065,30 +2127,40 @@ class ResolvedHostedDescription extends ResolvedDescription { /// (will be null if the file doesn't exist for corrupt or legacy caches). final Uint8List? sha256; + /// The verified Sigstore provenance repository of the package, or null if + /// not signed or unknown. + final String? provenance; + ResolvedHostedDescription( HostedDescription super.description, { required this.sha256, + this.provenance, }); @override Object? serializeForLockfile({required String? containingDir}) { final hash = sha256; + final prov = provenance; return { 'name': description.packageName, 'url': description.url, if (hash != null) 'sha256': hexEncode(hash), + if (prov != null) 'provenance': prov, }; } @override // We do not include the sha256 in the hashCode because of the equality // semantics. - int get hashCode => description.hashCode; + int get hashCode => Object.hash(description, provenance); @override bool operator ==(Object other) { return other is ResolvedHostedDescription && other.description == description && + (provenance == null || + other.provenance == null || + provenance == other.provenance) && // A [sha256] of `null` means that we don't know the hash yet. // Therefore we have to assume it is equal to any known value. (sha256 == null || @@ -2097,7 +2169,18 @@ class ResolvedHostedDescription extends ResolvedDescription { } ResolvedHostedDescription withSha256(Uint8List? newSha256) => - ResolvedHostedDescription(description, sha256: newSha256); + ResolvedHostedDescription( + description, + sha256: newSha256, + provenance: provenance, + ); + + ResolvedHostedDescription withProvenance(String? newProvenance) => + ResolvedHostedDescription( + description, + sha256: sha256, + provenance: newProvenance ?? provenance, + ); } /// Information about a package version retrieved from /api/packages/$package< diff --git a/pubspec.lock b/pubspec.lock index 5c2719140b9..b7e7d7aee53 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -65,6 +65,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" collection: dependency: "direct main" description: @@ -105,6 +113,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.5.2" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" file: dependency: "direct main" description: @@ -137,6 +153,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + hooks: + dependency: transitive + description: + name: hooks + sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f + url: "https://pub.dev" + source: hosted + version: "2.2.0" http: dependency: "direct main" description: @@ -197,10 +221,10 @@ packages: dependency: "direct main" description: name: meta - sha256: "9f29b9bcc8ee287b1a31e0d01be0eae99a930dbffdaecf04b3f3d82a969f296f" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.18.1" + version: "1.19.0" mime: dependency: transitive description: @@ -249,6 +273,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2" + url: "https://pub.dev" + source: hosted + version: "1.1.1" shelf: dependency: "direct main" description: @@ -292,12 +324,11 @@ packages: sigstore: dependency: "direct main" description: - path: "pkgs/sigstore" - ref: add-package-sigstore - resolved-ref: "519c07b150065b2f0b90a376ac09913c309beb30" - url: "https://github.com/dart-lang/labs.git" - source: git - version: "0.1.0-dev" + name: sigstore + sha256: "04c0dda858b534915faa82a9a877e94169c9b9d4546bba5a83f940c78b32736f" + url: "https://pub.dev" + source: hosted + version: "0.1.0" source_map_stack_trace: dependency: transitive description: @@ -475,4 +506,4 @@ packages: source: hosted version: "2.2.2" sdks: - dart: ">=3.9.0 <4.0.0" + dart: ">=3.10.0 <4.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index c35aea76270..3a2e30a9cb2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -24,11 +24,7 @@ dependencies: pool: ^1.0.0 pub_semver: ^2.2.0 shelf: ^1.4.2 - sigstore: - git: - url: https://github.com/dart-lang/labs.git - ref: add-package-sigstore - path: pkgs/sigstore + sigstore: ^0.1.0 source_span: ^1.10.2 stack_trace: ^1.12.1 tar: ^2.0.0 diff --git a/test/get/hosted/attestation_verification_test.dart b/test/get/hosted/attestation_verification_test.dart index 8bd863bea1b..2a910b97061 100644 --- a/test/get/hosted/attestation_verification_test.dart +++ b/test/get/hosted/attestation_verification_test.dart @@ -5,129 +5,20 @@ @TestOn('vm') library; -import 'dart:convert'; import 'dart:io'; -import 'package:path/path.dart' as p; import 'package:pub/src/exit_codes.dart'; import 'package:shelf/shelf.dart'; import 'package:test/test.dart'; import '../../descriptor.dart' as d; +import '../../sigstore/test_fixtures.dart'; import '../../test_pub.dart'; void main() { - Map createTestBundleJson({ - required String archiveSha256, - String packageName = 'foo', - String packageVersion = '1.0.0', - String repository = 'https://github.com/dart-lang/foo', - String issuer = 'https://token.actions.githubusercontent.com', - }) { - final statement = { - '_type': 'https://in-toto.io/Statement/v1', - 'subject': [ - { - 'name': '$packageName-$packageVersion.tar.gz', - 'digest': {'sha256': archiveSha256}, - }, - ], - 'predicateType': 'https://slsa.dev/provenance/v1', - 'predicate': { - 'buildDefinition': { - 'buildType': 'https://actions.github.io/buildtypes/workflow/v1', - 'externalParameters': { - 'workflow': { - 'ref': 'refs/tags/v$packageVersion', - 'repository': repository, - 'path': '.github/workflows/publish.yaml', - }, - }, - 'resolvedDependencies': [ - { - 'uri': 'git+$repository@refs/tags/v$packageVersion', - 'digest': { - 'gitCommit': '7891abbe3dab159e9d0187fc1042d5e0cd82cfad', - }, - }, - ], - }, - 'runDetails': { - 'builder': { - 'id': - 'https://github.com/dart-lang/ecosystem/.github/workflows/publish.yaml@refs/heads/main', - }, - }, - }, - }; - - final payloadBase64 = base64Encode(utf8.encode(jsonEncode(statement))); - - final derBytes = [ - 0x30, - 0x82, - 0x01, - 0x00, - ...utf8.encode(repository), - ...utf8.encode(issuer), - ]; - - return { - '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': payloadBase64, - 'signatures': [ - {'sig': base64Encode(utf8.encode('test-signature'))}, - ], - }, - }; - } - - final mockTrustedRoot = { - 'mediaType': 'application/vnd.dev.sigstore.trustedroot+json;version=0.1', - 'certificateAuthorities': [ - { - 'subject': {'organization': 'sigstore.dev', 'commonName': 'fulcio'}, - 'uri': 'https://fulcio.sigstore.dev', - }, - ], - 'tlogs': [ - { - 'baseUrl': 'https://rekor.sigstore.dev', - 'logId': {'keyId': 'test-rekor-key-id'}, - }, - ], - }; - - test('verifies attestation during pub get if served by repository', () async { + test('proceeds when no attestation is served by repository', () async { final server = await servePackages(); - server.serve('foo', '1.0.0', slsaLevel: 2); - - final rootPath = p.join(d.sandbox, 'trusted_root.json'); - File(rootPath).writeAsStringSync(jsonEncode(mockTrustedRoot)); - - final archiveSha = await server.peekArchiveSha256('foo', '1.0.0'); - final bundleJson = createTestBundleJson(archiveSha256: archiveSha); - - server.handle('/api/packages/foo/versions/1.0.0/attestation', (request) { - return Response.ok( - jsonEncode(bundleJson), - headers: {'content-type': 'application/json; charset="utf-8"'}, - ); - }); + server.serve('foo', '1.0.0'); await d .appDir( @@ -137,7 +28,7 @@ void main() { ) .create(); - await pubGet(environment: {'PUB_SIGSTORE_TRUST_ROOT': rootPath}); + await pubGet(); final cacheDir = server.pathInCache('foo', '1.0.0'); expect(Directory(cacheDir).existsSync(), isTrue); }); @@ -148,17 +39,9 @@ void main() { final server = await servePackages(); server.serve('foo', '1.0.0', slsaLevel: 2); - final rootPath = p.join(d.sandbox, 'trusted_root.json'); - File(rootPath).writeAsStringSync(jsonEncode(mockTrustedRoot)); - - // Tampered sha - const fakeSha = - '0000000000000000000000000000000000000000000000000000000000000000'; - final bundleJson = createTestBundleJson(archiveSha256: fakeSha); - server.handle('/api/packages/foo/versions/1.0.0/attestation', (request) { return Response.ok( - jsonEncode(bundleJson), + sampleBundleJson, headers: {'content-type': 'application/json; charset="utf-8"'}, ); }); @@ -172,7 +55,6 @@ void main() { .create(); await pubGet( - environment: {'PUB_SIGSTORE_TRUST_ROOT': rootPath}, error: contains('failed Sigstore attestation verification'), exitCode: TEMP_FAIL, ); diff --git a/test/sigstore/test_fixtures.dart b/test/sigstore/test_fixtures.dart new file mode 100644 index 00000000000..f537d135542 --- /dev/null +++ b/test/sigstore/test_fixtures.dart @@ -0,0 +1,18 @@ +// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:convert'; +import 'dart:typed_data'; + +final Uint8List sampleArtifactBytes = Uint8List.fromList( + utf8.encode( + 'DO NOT MODIFY ME!\n\n' + 'this is "a.txt", a sample input for ' + "sigstore-conformance's test suite.\n\n" + 'DO NOT MODIFY ME!\n', + ), +); + +const String sampleBundleJson = + r'''{"mediaType": "application/vnd.dev.sigstore.bundle+json;version=0.3", "verificationMaterial": {"certificate": {"rawBytes": "MIIIMTCCB7egAwIBAgIUaL/tsmQTHk21mt1Uuk+w7avDBz4wCgYIKoZIzj0EAwMwNzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwHhcNMjQwMzE5MTcyNjI2WhcNMjQwMzE5MTczNjI2WjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE22S1j/NkEXzBPQAuamHXLpwx+RPnnzZQl/pkEZ8xorvKnzujCS1mVTBo9kBxmYWo2DHtyVyfgnuOqVTzLYmho6OCBtYwggbSMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQUFv1SCziEKN2rRyrjeVlFbSLg1/QwHwYDVR0jBBgwFoAU39Ppz1YkEZb5qNjpKFWixi4YZD8wgaUGA1UdEQEB/wSBmjCBl4aBlGh0dHBzOi8vZ2l0aHViLmNvbS9zaWdzdG9yZS1jb25mb3JtYW5jZS9leHRyZW1lbHktZGFuZ2Vyb3VzLXB1YmxpYy1vaWRjLWJlYWNvbi8uZ2l0aHViL3dvcmtmbG93cy9leHRyZW1lbHktZGFuZ2Vyb3VzLW9pZGMtYmVhY29uLnltbEByZWZzL2hlYWRzL21haW4wOQYKKwYBBAGDvzABAQQraHR0cHM6Ly90b2tlbi5hY3Rpb25zLmdpdGh1YnVzZXJjb250ZW50LmNvbTAfBgorBgEEAYO/MAECBBF3b3JrZmxvd19kaXNwYXRjaDA2BgorBgEEAYO/MAEDBChjN2IzZGZiMzM1ZjA1MWUxYzg2YmRhNGM3MTZmYWM5N2RmNjJhZDgxMC0GCisGAQQBg78wAQQEH0V4dHJlbWVseSBkYW5nZXJvdXMgT0lEQyBiZWFjb24wSQYKKwYBBAGDvzABBQQ7c2lnc3RvcmUtY29uZm9ybWFuY2UvZXh0cmVtZWx5LWRhbmdlcm91cy1wdWJsaWMtb2lkYy1iZWFjb24wHQYKKwYBBAGDvzABBgQPcmVmcy9oZWFkcy9tYWluMDsGCisGAQQBg78wAQgELQwraHR0cHM6Ly90b2tlbi5hY3Rpb25zLmdpdGh1YnVzZXJjb250ZW50LmNvbTCBpgYKKwYBBAGDvzABCQSBlwyBlGh0dHBzOi8vZ2l0aHViLmNvbS9zaWdzdG9yZS1jb25mb3JtYW5jZS9leHRyZW1lbHktZGFuZ2Vyb3VzLXB1YmxpYy1vaWRjLWJlYWNvbi8uZ2l0aHViL3dvcmtmbG93cy9leHRyZW1lbHktZGFuZ2Vyb3VzLW9pZGMtYmVhY29uLnltbEByZWZzL2hlYWRzL21haW4wOAYKKwYBBAGDvzABCgQqDChjN2IzZGZiMzM1ZjA1MWUxYzg2YmRhNGM3MTZmYWM5N2RmNjJhZDgxMB0GCisGAQQBg78wAQsEDwwNZ2l0aHViLWhvc3RlZDBeBgorBgEEAYO/MAEMBFAMTmh0dHBzOi8vZ2l0aHViLmNvbS9zaWdzdG9yZS1jb25mb3JtYW5jZS9leHRyZW1lbHktZGFuZ2Vyb3VzLXB1YmxpYy1vaWRjLWJlYWNvbjA4BgorBgEEAYO/MAENBCoMKGM3YjNkZmIzMzVmMDUxZTFjODZiZGE0YzcxNmZhYzk3ZGY2MmFkODEwHwYKKwYBBAGDvzABDgQRDA9yZWZzL2hlYWRzL21haW4wGQYKKwYBBAGDvzABDwQLDAk2MzI1OTY4OTcwNwYKKwYBBAGDvzABEAQpDCdodHRwczovL2dpdGh1Yi5jb20vc2lnc3RvcmUtY29uZm9ybWFuY2UwGQYKKwYBBAGDvzABEQQLDAkxMzE4MDQ1NjMwgaYGCisGAQQBg78wARIEgZcMgZRodHRwczovL2dpdGh1Yi5jb20vc2lnc3RvcmUtY29uZm9ybWFuY2UvZXh0cmVtZWx5LWRhbmdlcm91cy1wdWJsaWMtb2lkYy1iZWFjb24vLmdpdGh1Yi93b3JrZmxvd3MvZXh0cmVtZWx5LWRhbmdlcm91cy1vaWRjLWJlYWNvbi55bWxAcmVmcy9oZWFkcy9tYWluMDgGCisGAQQBg78wARMEKgwoYzdiM2RmYjMzNWYwNTFlMWM4NmJkYTRjNzE2ZmFjOTdkZjYyYWQ4MTAhBgorBgEEAYO/MAEUBBMMEXdvcmtmbG93X2Rpc3BhdGNoMIGBBgorBgEEAYO/MAEVBHMMcWh0dHBzOi8vZ2l0aHViLmNvbS9zaWdzdG9yZS1jb25mb3JtYW5jZS9leHRyZW1lbHktZGFuZ2Vyb3VzLXB1YmxpYy1vaWRjLWJlYWNvbi9hY3Rpb25zL3J1bnMvODM0NzQ4MTYyOC9hdHRlbXB0cy8xMBYGCisGAQQBg78wARYECAwGcHVibGljMIGKBgorBgEEAdZ5AgQCBHwEegB4AHYA3T0wasbHETJjGR4cmWc3AqJKXrjePK3/h4pygC8p7o4AAAGOV8AHpgAABAMARzBFAiBFeMbpFarlPwb0naTr4mjWDvXApOd9ORqOk36Brt9SmwIhAJJvjor+DXUXr7S3Vm9jVFT3CL0BxcKGj86m5mYzQvubMAoGCCqGSM49BAMDA2gAMGUCMA8lTixdS4iN9mAUduObcSJmhZLyvK7zaX05DLEDCgPWxDHk+JBZUKYRIuHHgwFnOwIxALMamo9dfENMzRgNCzYfp/y+rSOhVjXXE9mCn6BuJETlpRDfGvxUg/5LF9f4lYqozA=="}, "tlogEntries": [{"logIndex": "79571823", "logId": {"keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="}, "kindVersion": {"kind": "hashedrekord", "version": "0.0.1"}, "integratedTime": "1710869186", "inclusionPromise": {"signedEntryTimestamp": "MEYCIQDMNM49CNrcrpuvB9G3likdSse0miAkY0ILCqzRGP5ZJQIhAKnSS9GUSFVCar1+Sq3qoRtJIJ8x9tqRnQ8kuS1ojtTH"}, "inclusionProof": {"logIndex": "75408392", "rootHash": "Fnnj13Uu1jdksPc4HZLapKX329dVlD5+MGNsiqBq1XM=", "treeSize": "75408393", "hashes": ["1J7hRIEGvYdAyzEs+GhAE9L+38oHye3BhalgoQRZoo4=", "W/OUCkh/lqDDwbBkZgP7eTV/wx4WifD1wtfRLbavfxI=", "9wya2BEhfLGDfDRVN46OU2RXkozWCM1Z4qMu6SPiWoY=", "ZRs3lKAIlu0t0GtLupAcOu1y20nOaOshSKosWAqFO+w=", "BGqH+LzVuhuqCLiUvBJaB2hlsvtu2a15qq1WGw6mG44=", "OeS7D4kPES7ChE7kWSEmhbAMqBcKVj/z8/afMK4Y3pI=", "JtjqvAqFyXXYjWlZfDzElHpEzdBjsz1LmGFJuYx0kTU=", "s/ZIVcfcD4/nuZwUtQf4ydGsIAkGTPTzk3b0zhUC95k=", "YU1jZY/fp5tJdGF/i+/7ez8107O4/lOUp7acMPFEaOA=", "7Z18YLBAvejEV4nJHIKoks/xlijnhR005qTW2w4QtHg=", "98enzMaC+x5oCMvIZQA5z8vu2apDMCFvE/935NfuPw8="], "checkpoint": {"envelope": "rekor.sigstore.dev - 2605736670972794746\n75408393\nFnnj13Uu1jdksPc4HZLapKX329dVlD5+MGNsiqBq1XM=\n\n— rekor.sigstore.dev wNI9ajBFAiBTyiBM9WtyOTgohje6QZ5rFGJUdMq7Wk3A6oThE98SUgIhAMvxDwa7FyqRqg+YV3rdPPrfS23w19iK+piMSGVOmP5w\n"}}, "canonicalizedBody": "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaGFzaGVkcmVrb3JkIiwic3BlYyI6eyJkYXRhIjp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiJhMGNmYzcxMjcxZDZlMjc4ZTU3Y2QzMzJmZjk1N2MzZjcwNDNmZGRhMzU0YzRjYmIxOTBhMzBkNTZlZmEwMWJmIn19LCJzaWduYXR1cmUiOnsiY29udGVudCI6Ik1FVUNJQ1lGcS80YlRFZGx1cmdxVnVObXdDY0lXdTNOS09DZ3ZlV0FKQmllekowdUFpRUEyaTdVMTgrYVJwRnhMWWtzcjVIS0JRUXkwOHpFMDUwV0ljMFJ6S3VuRElBPSIsInB1YmxpY0tleSI6eyJjb250ZW50IjoiTFMwdExTMUNSVWRKVGlCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2sxSlNVbE5WRU5EUWpkbFowRjNTVUpCWjBsVllVd3ZkSE50VVZSSWF6SXhiWFF4VlhWckszYzNZWFpFUW5vMGQwTm5XVWxMYjFwSmVtb3dSVUYzVFhjS1RucEZWazFDVFVkQk1WVkZRMmhOVFdNeWJHNWpNMUoyWTIxVmRWcEhWakpOVWpSM1NFRlpSRlpSVVVSRmVGWjZZVmRrZW1SSE9YbGFVekZ3WW01U2JBcGpiVEZzV2tkc2FHUkhWWGRJYUdOT1RXcFJkMDE2UlRWTlZHTjVUbXBKTWxkb1kwNU5hbEYzVFhwRk5VMVVZM3BPYWtreVYycEJRVTFHYTNkRmQxbElDa3R2V2tsNmFqQkRRVkZaU1V0dldrbDZhakJFUVZGalJGRm5RVVV5TWxNeGFpOU9hMFZZZWtKUVVVRjFZVzFJV0V4d2QzZ3JVbEJ1Ym5wYVVXd3ZjR3NLUlZvNGVHOXlka3R1ZW5WcVExTXhiVlpVUW04NWEwSjRiVmxYYnpKRVNIUjVWbmxtWjI1MVQzRldWSHBNV1cxb2J6WlBRMEowV1hkbloySlRUVUUwUndwQk1WVmtSSGRGUWk5M1VVVkJkMGxJWjBSQlZFSm5UbFpJVTFWRlJFUkJTMEpuWjNKQ1owVkdRbEZqUkVGNlFXUkNaMDVXU0ZFMFJVWm5VVlZHZGpGVENrTjZhVVZMVGpKeVVubHlhbVZXYkVaaVUweG5NUzlSZDBoM1dVUldVakJxUWtKbmQwWnZRVlV6T1ZCd2VqRlphMFZhWWpWeFRtcHdTMFpYYVhocE5Ga0tXa1E0ZDJkaFZVZEJNVlZrUlZGRlFpOTNVMEp0YWtOQ2JEUmhRbXhIYURCa1NFSjZUMms0ZGxveWJEQmhTRlpwVEcxT2RtSlRPWHBoVjJSNlpFYzVlUXBhVXpGcVlqSTFiV0l6U25SWlZ6VnFXbE01YkdWSVVubGFWekZzWWtocmRGcEhSblZhTWxaNVlqTldla3hZUWpGWmJYaHdXWGt4ZG1GWFVtcE1WMHBzQ2xsWFRuWmlhVGgxV2pKc01HRklWbWxNTTJSMlkyMTBiV0pIT1ROamVUbHNaVWhTZVZwWE1XeGlTR3QwV2tkR2RWb3lWbmxpTTFaNlRGYzVjRnBIVFhRS1dXMVdhRmt5T1hWTWJteDBZa1ZDZVZwWFducE1NbWhzV1ZkU2Vrd3lNV2hoVnpSM1QxRlpTMHQzV1VKQ1FVZEVkbnBCUWtGUlVYSmhTRkl3WTBoTk5ncE1lVGt3WWpKMGJHSnBOV2haTTFKd1lqSTFla3h0WkhCa1IyZ3hXVzVXZWxwWVNtcGlNalV3V2xjMU1FeHRUblppVkVGbVFtZHZja0puUlVWQldVOHZDazFCUlVOQ1FrWXpZak5LY2xwdGVIWmtNVGxyWVZoT2QxbFlVbXBoUkVFeVFtZHZja0puUlVWQldVOHZUVUZGUkVKRGFHcE9Na2w2V2tkYWFVMTZUVEVLV21wQk1VMVhWWGhaZW1jeVdXMVNhRTVIVFROTlZGcHRXVmROTlU0eVVtMU9ha3BvV2tSbmVFMURNRWREYVhOSFFWRlJRbWMzT0hkQlVWRkZTREJXTkFwa1NFcHNZbGRXYzJWVFFtdFpWelZ1V2xoS2RtUllUV2RVTUd4RlVYbENhVnBYUm1waU1qUjNVMUZaUzB0M1dVSkNRVWRFZG5wQlFrSlJVVGRqTW14dUNtTXpVblpqYlZWMFdUSTVkVnB0T1hsaVYwWjFXVEpWZGxwWWFEQmpiVlowV2xkNE5VeFhVbWhpYldSc1kyMDVNV041TVhka1YwcHpZVmROZEdJeWJHc0tXWGt4YVZwWFJtcGlNalIzU0ZGWlMwdDNXVUpDUVVkRWRucEJRa0puVVZCamJWWnRZM2s1YjFwWFJtdGplVGwwV1Zkc2RVMUVjMGREYVhOSFFWRlJRZ3BuTnpoM1FWRm5SVXhSZDNKaFNGSXdZMGhOTmt4NU9UQmlNblJzWW1rMWFGa3pVbkJpTWpWNlRHMWtjR1JIYURGWmJsWjZXbGhLYW1JeU5UQmFWelV3Q2t4dFRuWmlWRU5DY0dkWlMwdDNXVUpDUVVkRWRucEJRa05SVTBKc2QzbENiRWRvTUdSSVFucFBhVGgyV2pKc01HRklWbWxNYlU1MllsTTVlbUZYWkhvS1pFYzVlVnBUTVdwaU1qVnRZak5LZEZsWE5XcGFVemxzWlVoU2VWcFhNV3hpU0d0MFdrZEdkVm95Vm5saU0xWjZURmhDTVZsdGVIQlplVEYyWVZkU2FncE1WMHBzV1ZkT2RtSnBPSFZhTW13d1lVaFdhVXd6WkhaamJYUnRZa2M1TTJONU9XeGxTRko1V2xjeGJHSklhM1JhUjBaMVdqSldlV0l6Vm5wTVZ6bHdDbHBIVFhSWmJWWm9XVEk1ZFV4dWJIUmlSVUo1V2xkYWVrd3lhR3haVjFKNlRESXhhR0ZYTkhkUFFWbExTM2RaUWtKQlIwUjJla0ZDUTJkUmNVUkRhR29LVGpKSmVscEhXbWxOZWsweFdtcEJNVTFYVlhoWmVtY3lXVzFTYUU1SFRUTk5WRnB0V1ZkTk5VNHlVbTFPYWtwb1drUm5lRTFDTUVkRGFYTkhRVkZSUWdwbk56aDNRVkZ6UlVSM2QwNWFNbXd3WVVoV2FVeFhhSFpqTTFKc1drUkNaVUpuYjNKQ1owVkZRVmxQTDAxQlJVMUNSa0ZOVkcxb01HUklRbnBQYVRoMkNsb3liREJoU0ZacFRHMU9kbUpUT1hwaFYyUjZaRWM1ZVZwVE1XcGlNalZ0WWpOS2RGbFhOV3BhVXpsc1pVaFNlVnBYTVd4aVNHdDBXa2RHZFZveVZua0tZak5XZWt4WVFqRlpiWGh3V1hreGRtRlhVbXBNVjBwc1dWZE9kbUpxUVRSQ1oyOXlRbWRGUlVGWlR5OU5RVVZPUWtOdlRVdEhUVE5aYWs1cldtMUplZ3BOZWxadFRVUlZlRnBVUm1wUFJGcHBXa2RGTUZsNlkzaE9iVnBvV1hwck0xcEhXVEpOYlVaclQwUkZkMGgzV1V0TGQxbENRa0ZIUkhaNlFVSkVaMUZTQ2tSQk9YbGFWMXA2VERKb2JGbFhVbnBNTWpGb1lWYzBkMGRSV1V0TGQxbENRa0ZIUkhaNlFVSkVkMUZNUkVGck1rMTZTVEZQVkZrMFQxUmpkMDUzV1VzS1MzZFpRa0pCUjBSMmVrRkNSVUZSY0VSRFpHOWtTRkozWTNwdmRrd3laSEJrUjJneFdXazFhbUl5TUhaak1teHVZek5TZG1OdFZYUlpNamwxV20wNWVRcGlWMFoxV1RKVmQwZFJXVXRMZDFsQ1FrRkhSSFo2UVVKRlVWRk1SRUZyZUUxNlJUUk5SRkV4VG1wTmQyZGhXVWREYVhOSFFWRlJRbWMzT0hkQlVrbEZDbWRhWTAxbldsSnZaRWhTZDJONmIzWk1NbVJ3WkVkb01WbHBOV3BpTWpCMll6SnNibU16VW5aamJWVjBXVEk1ZFZwdE9YbGlWMFoxV1RKVmRscFlhREFLWTIxV2RGcFhlRFZNVjFKb1ltMWtiR050T1RGamVURjNaRmRLYzJGWFRYUmlNbXhyV1hreGFWcFhSbXBpTWpSMlRHMWtjR1JIYURGWmFUa3pZak5LY2dwYWJYaDJaRE5OZGxwWWFEQmpiVlowV2xkNE5VeFhVbWhpYldSc1kyMDVNV041TVhaaFYxSnFURmRLYkZsWFRuWmlhVFUxWWxkNFFXTnRWbTFqZVRsdkNscFhSbXRqZVRsMFdWZHNkVTFFWjBkRGFYTkhRVkZSUW1jM09IZEJVazFGUzJkM2IxbDZaR2xOTWxKdFdXcE5lazVYV1hkT1ZFWnNUVmROTkU1dFNtc0tXVlJTYWs1NlJUSmFiVVpxVDFSa2ExcHFXWGxaVjFFMFRWUkJhRUpuYjNKQ1owVkZRVmxQTDAxQlJWVkNRazFOUlZoa2RtTnRkRzFpUnpreldESlNjQXBqTTBKb1pFZE9iMDFKUjBKQ1oyOXlRbWRGUlVGWlR5OU5RVVZXUWtoTlRXTlhhREJrU0VKNlQyazRkbG95YkRCaFNGWnBURzFPZG1KVE9YcGhWMlI2Q21SSE9YbGFVekZxWWpJMWJXSXpTblJaVnpWcVdsTTViR1ZJVW5sYVZ6RnNZa2hyZEZwSFJuVmFNbFo1WWpOV2VreFlRakZaYlhod1dYa3hkbUZYVW1vS1RGZEtiRmxYVG5aaWFUbG9XVE5TY0dJeU5YcE1NMG94WW01TmRrOUVUVEJPZWxFMFRWUlplVTlET1doa1NGSnNZbGhDTUdONU9IaE5RbGxIUTJselJ3cEJVVkZDWnpjNGQwRlNXVVZEUVhkSFkwaFdhV0pIYkdwTlNVZExRbWR2Y2tKblJVVkJaRm8xUVdkUlEwSklkMFZsWjBJMFFVaFpRVE5VTUhkaGMySklDa1ZVU21wSFVqUmpiVmRqTTBGeFNrdFljbXBsVUVzekwyZzBjSGxuUXpod04yODBRVUZCUjA5V09FRkljR2RCUVVKQlRVRlNla0pHUVdsQ1JtVk5ZbkFLUm1GeWJGQjNZakJ1WVZSeU5HMXFWMFIyV0VGd1QyUTVUMUp4VDJzek5rSnlkRGxUYlhkSmFFRktTblpxYjNJclJGaFZXSEkzVXpOV2JUbHFWa1pVTXdwRFREQkNlR05MUjJvNE5tMDFiVmw2VVhaMVlrMUJiMGREUTNGSFUwMDBPVUpCVFVSQk1tZEJUVWRWUTAxQk9HeFVhWGhrVXpScFRqbHRRVlZrZFU5aUNtTlRTbTFvV2t4NWRrczNlbUZZTURWRVRFVkVRMmRRVjNoRVNHc3JTa0phVlV0WlVrbDFTRWhuZDBadVQzZEplRUZNVFdGdGJ6bGtaa1ZPVFhwU1owNEtRM3BaWm5BdmVTdHlVMDlvVm1wWVdFVTViVU51TmtKMVNrVlViSEJTUkdaSGRuaFZaeTgxVEVZNVpqUnNXWEZ2ZWtFOVBRb3RMUzB0TFVWT1JDQkRSVkpVU1VaSlEwRlVSUzB0TFMwdENnPT0ifX19fQ=="}]}, "messageSignature": {"messageDigest": {"algorithm": "SHA2_256", "digest": "oM/HEnHW4njlfNMy/5V8P3BD/do1TEy7GQow1W76Ab8="}, "signature": "MEUCICYFq/4bTEdlurgqVuNmwCcIWu3NKOCgveWAJBiezJ0uAiEA2i7U18+aRpFxLYksr5HKBQQy08zE050WIc0RzKunDIA="}}'''; diff --git a/test/sigstore/verifier_test.dart b/test/sigstore/verifier_test.dart index f8c048effb8..911bab8402e 100644 --- a/test/sigstore/verifier_test.dart +++ b/test/sigstore/verifier_test.dart @@ -5,165 +5,69 @@ @TestOn('vm') library; -import 'dart:convert'; import 'dart:typed_data'; -import 'package:crypto/crypto.dart'; -import 'package:pub/src/path.dart'; import 'package:pub/src/sigstore/verifier.dart'; import 'package:pub_semver/pub_semver.dart'; import 'package:test/test.dart'; -import '../descriptor.dart' as d; +import 'test_fixtures.dart'; void main() { - final mockTrustedRoot = { - 'mediaType': 'application/vnd.dev.sigstore.trustedroot+json;version=0.1', - 'certificateAuthorities': [ - { - 'subject': {'organization': 'sigstore.dev', 'commonName': 'fulcio'}, - 'uri': 'https://fulcio.sigstore.dev', - }, - ], - 'tlogs': [ - { - 'baseUrl': 'https://rekor.sigstore.dev', - 'logId': {'keyId': 'test-rekor-key-id'}, - }, - ], - }; + test('successfully verifies valid package and attestation', () { + final verifier = PubAttestationVerifier(); + final bundle = SigstoreBundle.fromJson(sampleBundleJson); - Map createTestBundleJson({ - required String archiveSha256, - String packageName = 'helpful', - String packageVersion = '0.1.4', - String repository = 'https://github.com/mosuem/helpful', - String issuer = 'https://token.actions.githubusercontent.com', - }) { - final statement = { - '_type': 'https://in-toto.io/Statement/v1', - 'subject': [ - { - 'name': '$packageName-$packageVersion.tar.gz', - 'digest': {'sha256': archiveSha256}, - }, - ], - 'predicateType': 'https://slsa.dev/provenance/v1', - 'predicate': { - 'buildDefinition': { - 'buildType': 'https://actions.github.io/buildtypes/workflow/v1', - 'externalParameters': { - 'workflow': { - 'ref': 'refs/tags/v$packageVersion', - 'repository': repository, - 'path': '.github/workflows/publish.yaml', - }, - }, - 'resolvedDependencies': [ - { - 'uri': 'git+$repository@refs/tags/v$packageVersion', - 'digest': { - 'gitCommit': '7891abbe3dab159e9d0187fc1042d5e0cd82cfad', - }, - }, - ], - }, - 'runDetails': { - 'builder': { - 'id': - 'https://github.com/dart-lang/ecosystem/.github/workflows/publish.yaml@refs/heads/main', - }, - }, - }, - }; - - final payloadBase64 = base64Encode(utf8.encode(jsonEncode(statement))); - - final derBytes = [ - 0x30, - 0x82, - 0x01, - 0x00, - ...utf8.encode(repository), - ...utf8.encode(issuer), - ]; - - return { - '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': payloadBase64, - 'signatures': [ - {'sig': base64Encode(utf8.encode('test-signature'))}, - ], - }, - }; - } - - test('successfully verifies valid package and attestation', () async { - await d.dir('sdk_ca', [ - d.file('trusted_root.json', jsonEncode(mockTrustedRoot)), - ]).create(); - - final customPath = p.join(d.sandbox, 'sdk_ca', 'trusted_root.json'); - final verifier = PubAttestationVerifier( - overrideTrustedRootPath: customPath, + final result = verifier.verify( + packageName: 'sample', + packageVersion: Version(1, 0, 0), + archiveBytes: sampleArtifactBytes, + bundle: bundle, + expectedRepository: + 'https://github.com/sigstore-conformance/extremely-dangerous-public-oidc-beacon', ); - final archiveBytes = Uint8List.fromList( - utf8.encode('fake-archive-bytes-0.1.4'), + expect(result.isValid, isTrue); + expect(result.errors, isEmpty); + expect(result.packageName, equals('sample')); + expect( + result.repository, + equals( + 'https://github.com/sigstore-conformance/extremely-dangerous-public-oidc-beacon', + ), ); - final archiveSha = sha256.convert(archiveBytes).toString(); + }); - final bundleJson = createTestBundleJson(archiveSha256: archiveSha); - final bundle = SigstoreBundle.fromJson(bundleJson); + test('fails when archive bytes do not match attestation', () { + final verifier = PubAttestationVerifier(); + final bundle = SigstoreBundle.fromJson(sampleBundleJson); + + final tamperedBytes = Uint8List.fromList([1, 2, 3, 4, 5]); final result = verifier.verify( - packageName: 'helpful', - packageVersion: Version(0, 1, 4), - archiveBytes: archiveBytes, + packageName: 'sample', + packageVersion: Version(1, 0, 0), + archiveBytes: tamperedBytes, bundle: bundle, - expectedRepository: 'https://github.com/mosuem/helpful', - pubspecRepository: 'https://github.com/mosuem/helpful', ); - expect(result.isValid, isTrue); - expect(result.errors, isEmpty); - expect(result.packageName, equals('helpful')); - expect(result.repository, equals('https://github.com/mosuem/helpful')); + expect(result.isValid, isFalse); + expect(result.errors, isNotEmpty); }); - test('fails when archive sha256 does not match attestation', () { - final archiveBytes = Uint8List.fromList( - utf8.encode('tampered-archive-bytes'), - ); - const originalSha = - 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'; - - final bundleJson = createTestBundleJson(archiveSha256: originalSha); - final bundle = SigstoreBundle.fromJson(bundleJson); + test('fails when repository does not match expected repository', () { + final verifier = PubAttestationVerifier(); + final bundle = SigstoreBundle.fromJson(sampleBundleJson); - final verifier = AttestationVerifier(trustedRoot: mockTrustedRoot); final result = verifier.verify( - packageName: 'helpful', - packageVersion: Version(0, 1, 4), - archiveBytes: archiveBytes, + packageName: 'sample', + packageVersion: Version(1, 0, 0), + archiveBytes: sampleArtifactBytes, bundle: bundle, + expectedRepository: 'https://github.com/unexpected-owner/unexpected-repo', ); expect(result.isValid, isFalse); - expect(result.errors.any((e) => e.contains('SHA-256')), isTrue); + expect(result.errors.first, contains('does not match expected repository')); }); } From 336849c3870d2b93127d7fe8916a74fdf458ab3a Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 31 Aug 2026 14:51:21 +0000 Subject: [PATCH 06/12] Resolve Sigstore trusted root from pub cache and embedded fallback without SDK --- lib/src/sigstore/trusted_root.dart | 50 ++++++++++------------------ lib/src/sigstore/verifier.dart | 25 ++++++++------ lib/src/system_cache.dart | 4 +++ test/sigstore/trusted_root_test.dart | 25 +++++++++++++- 4 files changed, 60 insertions(+), 44 deletions(-) diff --git a/lib/src/sigstore/trusted_root.dart b/lib/src/sigstore/trusted_root.dart index ae77e8bb64a..76866f7d0f1 100644 --- a/lib/src/sigstore/trusted_root.dart +++ b/lib/src/sigstore/trusted_root.dart @@ -6,18 +6,19 @@ import 'dart:convert'; import '../exceptions.dart'; import '../io.dart'; -import '../path.dart'; import '../platform_info.dart'; -import '../sdk/dart.dart'; +import '../system_cache.dart'; /// Loads the Sigstore `trusted_root.json` root of trust. /// /// Looks in the following order: /// 1. An explicit override path passed in [overridePath]. /// 2. The `PUB_SIGSTORE_TRUST_ROOT` environment variable. -/// 3. The built SDK directory at `lib/_internal/sigstore/trusted_root.json`. -/// 4. The Dart repository checkout at `third_party/sigstore/trusted_root.json`. -String loadTrustedRootJson({String? overridePath}) { +/// 3. The cached trusted root in the pub cache (`$PUB_CACHE/sigstore/trusted_root.json`). +/// +/// Returns `null` if no custom or cached root is found, in which case +/// `package:sigstore` falls back to its built-in production trusted root. +String? loadTrustedRootJson({SystemCache? cache, String? overridePath}) { if (overridePath != null) { if (!fileExists(overridePath)) { throw DataException( @@ -37,37 +38,22 @@ String loadTrustedRootJson({String? overridePath}) { return readTextFile(envPath); } - // 1. Check in the built SDK layout: - final sdkPath = p.join( - DartSdk().rootDirectory, - 'lib', - '_internal', - 'sigstore', - 'trusted_root.json', - ); - if (fileExists(sdkPath)) { - return readTextFile(sdkPath); - } - - // 2. Check in the repository checkout layout (when running in repo/tests): - final repoPath = p.join( - p.dirname(DartSdk().rootDirectory), - 'third_party', - 'sigstore', - 'trusted_root.json', - ); - if (fileExists(repoPath)) { - return readTextFile(repoPath); + final cachePath = cache?.sigstoreTrustedRootPath; + if (cachePath != null && fileExists(cachePath)) { + return readTextFile(cachePath); } - throw ApplicationException( - 'Could not locate Sigstore trusted_root.json in the Dart SDK.', - ); + return null; } -/// Parses and returns the decoded JSON map of the Sigstore trusted root. -Map loadTrustedRoot({String? overridePath}) { - final text = loadTrustedRootJson(overridePath: overridePath); +/// Parses and returns the decoded JSON map of the Sigstore trusted root, +/// or `null` if no custom or cached root is found. +Map? loadTrustedRoot({ + SystemCache? cache, + String? overridePath, +}) { + final text = loadTrustedRootJson(cache: cache, overridePath: overridePath); + if (text == null) return null; try { return jsonDecode(text) as Map; } on FormatException catch (e) { diff --git a/lib/src/sigstore/verifier.dart b/lib/src/sigstore/verifier.dart index 4f711f0e1d6..64e7d4026ce 100644 --- a/lib/src/sigstore/verifier.dart +++ b/lib/src/sigstore/verifier.dart @@ -2,15 +2,15 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -import 'dart:io'; - import 'package:pub_semver/pub_semver.dart'; import '../system_cache.dart'; import 'sigstore.dart'; +import 'trusted_root.dart'; export 'lockfile_policy.dart'; export 'sigstore.dart'; +export 'trusted_root.dart'; /// Result of verifying a package attestation bundle. class AttestationVerificationResult { @@ -33,19 +33,20 @@ class AttestationVerificationResult { }); } -/// Attestation verifier in pub pre-configured with the Dart SDK's trusted root. +/// Attestation verifier in pub pre-configured with the Sigstore trusted root. class PubAttestationVerifier { - final String? _trustedRootPath; + final SystemCache? _cache; + final String? _overrideTrustedRootPath; final String? _overrideTrustedRootJson; final bool _offline; PubAttestationVerifier({ - // ignore: avoid_unused_constructor_parameters SystemCache? cache, String? overrideTrustedRootPath, String? overrideTrustedRootJson, bool offline = true, - }) : _trustedRootPath = overrideTrustedRootPath, + }) : _cache = cache, + _overrideTrustedRootPath = overrideTrustedRootPath, _overrideTrustedRootJson = overrideTrustedRootJson, _offline = offline; @@ -59,11 +60,13 @@ class PubAttestationVerifier { }) { try { var trustedRootJson = _overrideTrustedRootJson ?? ''; - if (trustedRootJson.isEmpty && _trustedRootPath != null) { - final f = File(_trustedRootPath); - if (f.existsSync()) { - trustedRootJson = f.readAsStringSync(); - } + if (trustedRootJson.isEmpty) { + trustedRootJson = + loadTrustedRootJson( + cache: _cache, + overridePath: _overrideTrustedRootPath, + ) ?? + ''; } final client = SigstoreClient.create(); diff --git a/lib/src/system_cache.dart b/lib/src/system_cache.dart index 3e38e3a163f..bea03c2e8b0 100644 --- a/lib/src/system_cache.dart +++ b/lib/src/system_cache.dart @@ -49,6 +49,10 @@ class SystemCache { String get tempDir => p.join(rootDir, '_temp'); + /// The path to the cached Sigstore trusted root in the pub cache. + String get sigstoreTrustedRootPath => + p.join(rootDir, 'sigstore', 'trusted_root.json'); + static String defaultDir = (() { final envCache = platform.environment['PUB_CACHE']; diff --git a/test/sigstore/trusted_root_test.dart b/test/sigstore/trusted_root_test.dart index 0087a5b5f75..a62e7dc7dd6 100644 --- a/test/sigstore/trusted_root_test.dart +++ b/test/sigstore/trusted_root_test.dart @@ -8,6 +8,7 @@ library; import 'package:pub/src/exceptions.dart'; import 'package:pub/src/path.dart'; import 'package:pub/src/sigstore/trusted_root.dart'; +import 'package:pub/src/system_cache.dart'; import 'package:test/test.dart'; import '../descriptor.dart' as d; @@ -24,7 +25,7 @@ void main() { final customPath = p.join(d.sandbox, 'custom_ca', 'trusted_root.json'); final root = loadTrustedRoot(overridePath: customPath); - expect(root['mediaType'], equals('test')); + expect(root!['mediaType'], equals('test')); expect(root['certificateAuthorities'], isEmpty); }); @@ -46,4 +47,26 @@ void main() { throwsA(isA()), ); }); + + test('loads trusted_root.json from pub cache', () async { + await d.dir('cache', [ + d.dir('sigstore', [ + d.file( + 'trusted_root.json', + '{"mediaType":"cache_test","certificateAuthorities":[]}', + ), + ]), + ]).create(); + + final cache = SystemCache(rootDir: p.join(d.sandbox, 'cache')); + final root = loadTrustedRoot(cache: cache); + + expect(root!['mediaType'], equals('cache_test')); + expect(root['certificateAuthorities'], isEmpty); + }); + + test('returns null when no override or cache is found', () { + final root = loadTrustedRoot(); + expect(root, isNull); + }); } From 234d04ebce1ed4c53edccf701b7ebc16c1ff5b7d Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 7 Sep 2026 13:55:52 +0000 Subject: [PATCH 07/12] Enforce GitHub repository and identity in PubAttestationVerifier --- lib/src/sigstore/verifier.dart | 26 ++++++++++++++++++++++++++ test/sigstore/verifier_test.dart | 19 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/lib/src/sigstore/verifier.dart b/lib/src/sigstore/verifier.dart index 64e7d4026ce..cfc699017ac 100644 --- a/lib/src/sigstore/verifier.dart +++ b/lib/src/sigstore/verifier.dart @@ -99,10 +99,36 @@ class PubAttestationVerifier { if (parts.length >= 2) { repo = 'https://github.com/${parts[0]}/${parts[1]}'; } + } else { + return AttestationVerificationResult( + isValid: false, + packageName: packageName, + packageVersion: packageVersion, + signerIdentity: identity, + oidcIssuer: issuer, + errors: [ + 'Package attestation verification is currently only supported ' + 'for GitHub repositories (signer identity: "$identity").', + ], + ); } final targetRepo = expectedRepository ?? pubspecRepository; if (targetRepo != null && targetRepo.isNotEmpty) { + if (!targetRepo.contains('github.com')) { + return AttestationVerificationResult( + isValid: false, + packageName: packageName, + packageVersion: packageVersion, + repository: repo, + signerIdentity: identity, + oidcIssuer: issuer, + errors: [ + 'Package attestation verification is currently only supported ' + 'for GitHub repositories (got: "$targetRepo").', + ], + ); + } if (repo == null || !_repositoriesMatch(repo, targetRepo)) { final msg = 'Attestation identity "$identity" does not match expected ' diff --git a/test/sigstore/verifier_test.dart b/test/sigstore/verifier_test.dart index 911bab8402e..bc28cc586a0 100644 --- a/test/sigstore/verifier_test.dart +++ b/test/sigstore/verifier_test.dart @@ -70,4 +70,23 @@ void main() { expect(result.isValid, isFalse); expect(result.errors.first, contains('does not match expected repository')); }); + + test('fails when expected repository is not on GitHub', () { + final verifier = PubAttestationVerifier(); + final bundle = SigstoreBundle.fromJson(sampleBundleJson); + + final result = verifier.verify( + packageName: 'sample', + packageVersion: Version(1, 0, 0), + archiveBytes: sampleArtifactBytes, + bundle: bundle, + expectedRepository: 'https://gitlab.com/unexpected-owner/unexpected-repo', + ); + + expect(result.isValid, isFalse); + expect( + result.errors.first, + contains('currently only supported for GitHub repositories'), + ); + }); } From 248e03b88c500b03df387bc9d14cd1f6ae22472e Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 24 Sep 2026 08:29:06 +0000 Subject: [PATCH 08/12] Verify SLSA provenance payload, bind archive pubspec, and enforce TOFU policy --- doc/repository-spec-v2.md | 29 ++ lib/src/sigstore/lockfile_policy.dart | 27 +- lib/src/sigstore/provenance.dart | 344 ++++++++++++++++ lib/src/sigstore/sigstore_stub.dart | 8 + lib/src/sigstore/verifier.dart | 385 ++++++++++++++---- lib/src/source/hosted.dart | 236 ++++++++--- .../hosted/attestation_verification_test.dart | 114 ++++++ test/package_server.dart | 11 +- test/sigstore/policy_test.dart | 174 ++++++++ test/sigstore/provenance_test.dart | 196 +++++++++ test/sigstore/test_fixtures.dart | 75 ++++ test/sigstore/verifier_test.dart | 113 +++-- 12 files changed, 1534 insertions(+), 178 deletions(-) create mode 100644 lib/src/sigstore/provenance.dart create mode 100644 test/sigstore/policy_test.dart create mode 100644 test/sigstore/provenance_test.dart diff --git a/doc/repository-spec-v2.md b/doc/repository-spec-v2.md index 05f276a4f78..6f29aaacff1 100644 --- a/doc/repository-spec-v2.md +++ b/doc/repository-spec-v2.md @@ -233,6 +233,7 @@ server, this could work in many different ways. "latest": { "version": "", "retracted": true || false, /* optional field, false if omitted */ + "slsa_level": 2, /* optional field, omitted if the version is not attested */ "archive_url": "https://.../archive.tar.gz", "archive_sha256": "95cbaad58e2cf32d1aa852f20af1fcda1820ead92a4b1447ea7ba1ba18195d27" "pubspec": { @@ -243,6 +244,7 @@ server, this could work in many different ways. { "version": "", "retracted": true || false, /* optional field, false if omitted */ + "slsa_level": 2, /* optional field, omitted if the version is not attested */ "archive_url": "https://.../archive.tar.gz", "archive_sha256": "95cbaad58e2cf32d1aa852f20af1fcda1820ead92a4b1447ea7ba1ba18195d27" "pubspec": { @@ -285,6 +287,33 @@ that the advisories end-point is supported by the server. If present this must be a timestamp of when the result from the advisories end-point for this package changed. +The `slsa_level` property is optional. If present, the version has an +attestation, and the client will fetch and verify it, see +[Fetching Attestations](#fetching-attestations). + +### Fetching Attestations + +**GET** `/api/packages//versions//attestation` + +**Headers** +* `Accept: application/json` + +**Response** +* `Content-Type: application/json` + +The response body is the Sigstore bundle that was supplied when the version was +published, verbatim. + +A repository that reports `slsa_level` for a version **must** serve the +attestation for that version: a client that has been told a version is attested +will fail the download if the attestation cannot be retrieved, rather than +install it unverified. Repositories that do not support attestations should +respond `404` here and omit `slsa_level`. + +The attestation is not a substitute for the repository being trustworthy about +*which* versions are attested; it is what allows the client to check the archive +without trusting the repository about its *contents*. + ## Publishing Packages **GET** `/api/packages/versions/new` diff --git a/lib/src/sigstore/lockfile_policy.dart b/lib/src/sigstore/lockfile_policy.dart index 37b1c059b1f..db67adc93b9 100644 --- a/lib/src/sigstore/lockfile_policy.dart +++ b/lib/src/sigstore/lockfile_policy.dart @@ -4,13 +4,12 @@ import 'package:pub_semver/pub_semver.dart'; -/// Exception thrown when package integrity or provenance policies fail. -class PackageProvenanceException implements Exception { - final String message; - PackageProvenanceException(this.message); +import '../exceptions.dart'; +import 'provenance.dart'; - @override - String toString() => message; +/// Exception thrown when package integrity or provenance policies fail. +class PackageProvenanceException extends DataException { + PackageProvenanceException(super.message); } /// Compact provenance summary stored in memory or lockfile. @@ -106,16 +105,10 @@ Downgrading from a signed package to an unsigned package is prohibited. } static bool _repositoriesMatch(String a, String b) { - final normA = a - .trim() - .toLowerCase() - .replaceAll(RegExp(r'\.git$'), '') - .replaceAll(RegExp(r'/+$'), ''); - final normB = b - .trim() - .toLowerCase() - .replaceAll(RegExp(r'\.git$'), '') - .replaceAll(RegExp(r'/+$'), ''); - return normA == normB || normA.endsWith(normB) || normB.endsWith(normA); + final repoA = GitHubRepository.tryParse(a); + final repoB = GitHubRepository.tryParse(b); + if (repoA != null && repoB != null) return repoA == repoB; + // Not (both) GitHub repositories - fall back to exact comparison. + return a.trim().toLowerCase() == b.trim().toLowerCase(); } } diff --git a/lib/src/sigstore/provenance.dart b/lib/src/sigstore/provenance.dart new file mode 100644 index 00000000000..6e611388705 --- /dev/null +++ b/lib/src/sigstore/provenance.dart @@ -0,0 +1,344 @@ +// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// Parsing of the SLSA build provenance carried in a Sigstore bundle. +/// +/// `package:sigstore` verifies the cryptographic material of a bundle, and +/// exposes the signer identity from the Fulcio certificate. It does not expose +/// the signed payload, so we parse the DSSE envelope of the bundle ourselves. +/// +/// This is safe as long as the exact same bundle JSON is handed to +/// `package:sigstore` for signature verification and to +/// [BuildProvenance.parseBundle] - the payload we inspect here is then +/// guaranteed to be the payload the signature was verified over. To make it +/// hard to get this wrong, `PubAttestationVerifier.verify` takes the bundle as +/// JSON and does both. +library; + +import 'dart:convert'; + +/// The OIDC issuer of tokens minted by GitHub Actions. +const githubActionsOidcIssuer = 'https://token.actions.githubusercontent.com'; + +const _inTotoStatementType = 'https://in-toto.io/Statement/v1'; +const _inTotoPayloadType = 'application/vnd.in-toto+json'; +const _slsaProvenancePredicateType = 'https://slsa.dev/provenance/v1'; + +/// A GitHub repository, identified by its [owner] and [name]. +/// +/// GitHub treats owner and repository names case-insensitively, and so does +/// [operator ==]. +class GitHubRepository { + final String owner; + final String name; + + GitHubRepository(this.owner, this.name); + + /// Parses a reference to a GitHub repository, or returns `null` if [source] + /// does not denote one. + /// + /// Accepts the forms used in `pubspec.yaml`'s `repository:` field and in + /// SLSA provenance statements: + /// + /// * `https://github.com//` + /// * `https://github.com//.git` + /// * `git+https://github.com//@` + /// * `https://github.com///tree//` (monorepos) + /// + /// Everything after `` is ignored: a package living in a subdirectory + /// of a monorepo is published from the repository as a whole, so the + /// subdirectory carries no information for verification. + static GitHubRepository? tryParse(String source) { + var s = source.trim(); + if (s.isEmpty) return null; + // Provenance uses SPDX-style download locations such as + // `git+https://github.com/dart-lang/pub@refs/tags/v1.0.0`. + if (s.startsWith('git+')) { + s = s.substring('git+'.length); + final at = s.lastIndexOf('@'); + if (at > 0) s = s.substring(0, at); + } + final uri = Uri.tryParse(s); + if (uri == null) return null; + if (uri.scheme != 'http' && uri.scheme != 'https') return null; + // Compare the full host: `endsWith('github.com')` would also accept + // `github.com.example.com`. + final host = uri.host.toLowerCase(); + if (host != 'github.com' && host != 'www.github.com') return null; + final segments = uri.pathSegments.where((s) => s.isNotEmpty).toList(); + if (segments.length < 2) return null; + final owner = segments[0]; + var name = segments[1]; + if (name.endsWith('.git')) { + name = name.substring(0, name.length - '.git'.length); + } + if (owner.isEmpty || name.isEmpty) return null; + return GitHubRepository(owner, name); + } + + /// `/`, as used by the GitHub API and in OIDC claims. + String get slug => '$owner/$name'; + + /// The canonical url of this repository. + String get url => 'https://github.com/$owner/$name'; + + @override + bool operator ==(Object other) => + other is GitHubRepository && + owner.toLowerCase() == other.owner.toLowerCase() && + name.toLowerCase() == other.name.toLowerCase(); + + @override + int get hashCode => Object.hash(owner.toLowerCase(), name.toLowerCase()); + + @override + String toString() => url; +} + +/// The identity of the workflow that signed an attestation. +/// +/// This is the `job_workflow_ref` claim of the GitHub Actions OIDC token, +/// which Fulcio puts in the subject alternative name of the signing +/// certificate. For a reusable workflow it identifies the *reusable* workflow +/// - the trusted builder - and not the repository that called it. +class GitHubWorkflowIdentity { + final GitHubRepository repository; + + /// The path of the workflow file within [repository], e.g. + /// `.github/workflows/publish.yml`. + final String path; + + /// The git ref the workflow ran at, e.g. `refs/tags/v2`. + final String ref; + + GitHubWorkflowIdentity({ + required this.repository, + required this.path, + required this.ref, + }); + + /// Parses an identity of the form + /// `https://github.com///@`. + static GitHubWorkflowIdentity? tryParse(String identity) { + final at = identity.lastIndexOf('@'); + if (at <= 0) return null; + final url = identity.substring(0, at); + final ref = identity.substring(at + 1); + if (ref.isEmpty) return null; + final repository = GitHubRepository.tryParse(url); + if (repository == null) return null; + final uri = Uri.tryParse(url); + if (uri == null) return null; + final segments = uri.pathSegments.where((s) => s.isNotEmpty).toList(); + if (segments.length < 3) return null; + return GitHubWorkflowIdentity( + repository: repository, + path: segments.sublist(2).join('/'), + ref: ref, + ); + } + + /// `//` - the workflow without the ref it ran at. + String get workflow => '${repository.slug}/$path'; + + @override + String toString() => '${repository.url}/$path@$ref'; +} + +/// An entry of the `subject` list of an in-toto statement. +class ProvenanceSubject { + final String name; + + /// The lowercase hex-encoded sha256 digest, or `null` if the subject is not + /// identified by a sha256. + final String? sha256; + + ProvenanceSubject(this.name, this.sha256); +} + +/// The SLSA build provenance of an artifact, as carried in the DSSE envelope +/// of a Sigstore bundle. +class BuildProvenance { + /// The repository the build ran for. + /// + /// This is `github.repository` of the workflow run - for a reusable + /// workflow, the repository that *called* the trusted builder. It is filled + /// in by GitHub from the run context and cannot be set by the caller. + final GitHubRepository sourceRepository; + + /// The ref of [sourceRepository] the build ran at, e.g. `refs/tags/v1.0.0`. + final String? sourceRef; + + /// The path of the caller's workflow file. + final String? workflowPath; + + /// The commit the build ran at, if recorded. + final String? commitSha; + + /// The builder as self-reported in the statement. + /// + /// Note that the authoritative statement about who signed is the certificate + /// identity, not this field. + final GitHubWorkflowIdentity? builder; + + final List subjects; + + BuildProvenance({ + required this.sourceRepository, + required this.sourceRef, + required this.workflowPath, + required this.commitSha, + required this.builder, + required this.subjects, + }); + + /// Extracts the build provenance from the DSSE envelope of the Sigstore + /// bundle in [bundleJson]. + /// + /// Throws a [FormatException] if [bundleJson] is not a bundle carrying a + /// SLSA build provenance statement. + /// + /// This does not validate any signature. Only use the result if + /// `package:sigstore` has verified the very same [bundleJson]. + factory BuildProvenance.parseBundle(String bundleJson) { + final Object? decoded; + try { + decoded = json.decode(bundleJson); + } on FormatException catch (e) { + throw FormatException('Attestation is not valid JSON: ${e.message}'); + } + final bundle = _expectMap(decoded, 'bundle'); + final envelope = bundle['dsseEnvelope']; + if (envelope == null) { + throw const FormatException( + 'Attestation does not contain a `dsseEnvelope`, so it carries no ' + 'build provenance.', + ); + } + final dsse = _expectMap(envelope, 'dsseEnvelope'); + final payloadType = dsse['payloadType']; + if (payloadType != _inTotoPayloadType) { + throw FormatException( + 'Attestation payload has type `$payloadType`, ' + 'expected `$_inTotoPayloadType`.', + ); + } + final encodedPayload = dsse['payload']; + if (encodedPayload is! String) { + throw const FormatException('Attestation has no `payload`.'); + } + final Object? decodedStatement; + try { + decodedStatement = json.decode( + utf8.decode(base64.decode(encodedPayload)), + ); + } on FormatException catch (e) { + throw FormatException( + 'Attestation payload is not valid base64-encoded JSON: ${e.message}', + ); + } + final statement = _expectMap(decodedStatement, 'statement'); + + final type = statement['_type']; + if (type != _inTotoStatementType) { + throw FormatException( + 'Attestation statement has type `$type`, ' + 'expected `$_inTotoStatementType`.', + ); + } + final predicateType = statement['predicateType']; + if (predicateType != _slsaProvenancePredicateType) { + throw FormatException( + 'Attestation has predicate type `$predicateType`, ' + 'expected `$_slsaProvenancePredicateType`.', + ); + } + + final subjects = []; + final subjectList = statement['subject']; + if (subjectList is! List || subjectList.isEmpty) { + throw const FormatException('Attestation statement has no `subject`.'); + } + for (final subject in subjectList) { + final s = _expectMap(subject, 'subject'); + final name = s['name']; + if (name is! String) { + throw const FormatException('Attestation subject has no `name`.'); + } + final digest = s['digest']; + final sha256 = digest is Map ? digest['sha256'] : null; + subjects.add( + ProvenanceSubject(name, sha256 is String ? sha256.toLowerCase() : null), + ); + } + + final predicate = _expectMap(statement['predicate'], 'predicate'); + final buildDefinition = _expectMap( + predicate['buildDefinition'], + 'buildDefinition', + ); + final externalParameters = _expectMap( + buildDefinition['externalParameters'], + 'externalParameters', + ); + final workflow = _expectMap(externalParameters['workflow'], 'workflow'); + + final repositoryField = workflow['repository']; + if (repositoryField is! String) { + throw const FormatException( + 'Attestation does not state the repository it was built from.', + ); + } + final sourceRepository = GitHubRepository.tryParse(repositoryField); + if (sourceRepository == null) { + throw FormatException( + 'Attestation was built from `$repositoryField`, which is not a GitHub ' + 'repository. Only GitHub is supported for now.', + ); + } + + String? commitSha; + final resolvedDependencies = buildDefinition['resolvedDependencies']; + if (resolvedDependencies is List) { + for (final dependency in resolvedDependencies) { + if (dependency is! Map) continue; + final uri = dependency['uri']; + if (uri is! String) continue; + if (GitHubRepository.tryParse(uri) != sourceRepository) continue; + final digest = dependency['digest']; + final gitCommit = digest is Map ? digest['gitCommit'] : null; + if (gitCommit is String) { + commitSha = gitCommit; + break; + } + } + } + + GitHubWorkflowIdentity? builder; + final runDetails = predicate['runDetails']; + if (runDetails is Map) { + final builderField = runDetails['builder']; + final id = builderField is Map ? builderField['id'] : null; + if (id is String) builder = GitHubWorkflowIdentity.tryParse(id); + } + + final ref = workflow['ref']; + final path = workflow['path']; + return BuildProvenance( + sourceRepository: sourceRepository, + sourceRef: ref is String ? ref : null, + workflowPath: path is String ? path : null, + commitSha: commitSha, + builder: builder, + subjects: subjects, + ); + } +} + +Map _expectMap(Object? value, String what) { + if (value is! Map) { + throw FormatException('Attestation `$what` is not a JSON object.'); + } + return value; +} diff --git a/lib/src/sigstore/sigstore_stub.dart b/lib/src/sigstore/sigstore_stub.dart index b01ef7788c5..c5c9533f6b5 100644 --- a/lib/src/sigstore/sigstore_stub.dart +++ b/lib/src/sigstore/sigstore_stub.dart @@ -4,6 +4,14 @@ /// Stub classes for `package:sigstore` when compiling for platforms without /// `dart:ffi`. +library; + +/// Mirrors the error thrown by `package:sigstore`. +/// +/// Note that this is a plain enum: it implements neither `Exception` nor +/// `Error`, so it is only caught by `on SigstoreError` or by a catch-all. +enum SigstoreError { invalidBundle, verificationFailed, internalError } + class SigstoreBundle { static SigstoreBundle fromJson(String json) => throw UnsupportedError('Sigstore is not supported on this platform.'); diff --git a/lib/src/sigstore/verifier.dart b/lib/src/sigstore/verifier.dart index cfc699017ac..e8c472ca7e5 100644 --- a/lib/src/sigstore/verifier.dart +++ b/lib/src/sigstore/verifier.dart @@ -2,35 +2,70 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. +import 'package:collection/collection.dart'; +import 'package:crypto/crypto.dart'; import 'package:pub_semver/pub_semver.dart'; import '../system_cache.dart'; +import 'lockfile_policy.dart'; +import 'provenance.dart'; import 'sigstore.dart'; import 'trusted_root.dart'; export 'lockfile_policy.dart'; +export 'provenance.dart'; export 'sigstore.dart'; export 'trusted_root.dart'; +/// The workflows trusted to sign attestations for packages on pub.dev, as +/// `//`. +/// +/// This is the *signer* of an attestation - the reusable workflow that ran the +/// signing step - and not the repository a package was published from. All +/// packages are signed by the same small set of workflows, which is why the +/// signer identity says nothing about which package was built. That binding +/// comes from the provenance statement, see +/// [BuildProvenance.sourceRepository]. +const defaultTrustedSignerWorkflows = { + 'dart-lang/setup-dart/.github/workflows/publish.yml', +}; + /// Result of verifying a package attestation bundle. class AttestationVerificationResult { final bool isValid; final String packageName; final Version? packageVersion; - final String? repository; + + /// The verified provenance, or `null` if verification failed. + final ProvenanceInfo? provenance; + + /// The identity of the workflow that signed the attestation. final String? signerIdentity; final String? oidcIssuer; final List errors; + /// The repository the package was built from, or `null` if verification + /// failed. + String? get repository => provenance?.repository; + AttestationVerificationResult({ required this.isValid, required this.packageName, this.packageVersion, - this.repository, + this.provenance, this.signerIdentity, this.oidcIssuer, this.errors = const [], }); + + AttestationVerificationResult.failure({ + required this.packageName, + this.packageVersion, + this.signerIdentity, + this.oidcIssuer, + required this.errors, + }) : isValid = false, + provenance = null; } /// Attestation verifier in pub pre-configured with the Sigstore trusted root. @@ -39,40 +74,73 @@ class PubAttestationVerifier { final String? _overrideTrustedRootPath; final String? _overrideTrustedRootJson; final bool _offline; + final Set _trustedSignerWorkflows; + final bool _requireTaggedSignerRef; PubAttestationVerifier({ SystemCache? cache, String? overrideTrustedRootPath, String? overrideTrustedRootJson, bool offline = true, + Set trustedSignerWorkflows = defaultTrustedSignerWorkflows, + bool requireTaggedSignerRef = false, }) : _cache = cache, _overrideTrustedRootPath = overrideTrustedRootPath, _overrideTrustedRootJson = overrideTrustedRootJson, - _offline = offline; + _offline = offline, + _trustedSignerWorkflows = trustedSignerWorkflows, + _requireTaggedSignerRef = requireTaggedSignerRef; + /// Verifies that [archiveBytes] is the archive of [packageName] at + /// [packageVersion], built by a trusted workflow from [expectedRepository]. + /// + /// [bundleJson] is the Sigstore bundle as served by the package repository. + /// It is passed as JSON (rather than as a parsed [SigstoreBundle]) so that + /// the signature check and the inspection of the signed statement are + /// guaranteed to happen on the same bytes. + /// + /// [expectedRepository] is the `repository:` field of the `pubspec.yaml` + /// *inside* [archiveBytes]. The pubspec of the version listing must not be + /// used: only the archive is covered by the attested digest. If + /// [requireRepository] is `true`, verification fails when + /// [expectedRepository] is missing - an attestation that is not bound to a + /// repository proves nothing beyond "somebody's trusted workflow built + /// this". AttestationVerificationResult verify({ required String packageName, required Version packageVersion, required List archiveBytes, - required SigstoreBundle bundle, + required String bundleJson, + String? declaredPackageName, + Version? declaredPackageVersion, String? expectedRepository, - String? pubspecRepository, + bool requireRepository = true, }) { + String? identity; + String? issuer; try { - var trustedRootJson = _overrideTrustedRootJson ?? ''; - if (trustedRootJson.isEmpty) { - trustedRootJson = - loadTrustedRootJson( - cache: _cache, - overridePath: _overrideTrustedRootPath, - ) ?? - ''; - } + // A `null` trusted root makes `package:sigstore` fall back to its + // built-in production root of trust. + final trustedRootJson = + _overrideTrustedRootJson ?? + loadTrustedRootJson( + cache: _cache, + overridePath: _overrideTrustedRootPath, + ) ?? + ''; + + // Throws [SigstoreError.invalidBundle], handled below. + final bundle = SigstoreBundle.fromJson(bundleJson); final client = SigstoreClient.create(); + // The expected identity is left unrestricted here and enforced below + // against [_trustedSignerWorkflows]: we accept a set of workflows, and + // the ref they ran at varies. An empty string means "any identity" to + // `package:sigstore`, so *this call alone does not authenticate the + // signer*. final policy = SigstoreVerificationPolicy.create( '', - 'https://token.actions.githubusercontent.com', + githubActionsOidcIssuer, _offline, false, trustedRootJson, @@ -81,99 +149,258 @@ class PubAttestationVerifier { final result = client.verify(archiveBytes, false, bundle, policy); if (!result.isValid()) { - return AttestationVerificationResult( - isValid: false, + return AttestationVerificationResult.failure( packageName: packageName, packageVersion: packageVersion, errors: ['Attestation signature verification failed'], ); } - final identity = result.verifiedIdentity(); - final issuer = result.verifiedIssuer(); - String? repo; - if (identity.startsWith('https://github.com/')) { - final parts = identity - .substring('https://github.com/'.length) - .split('/'); - if (parts.length >= 2) { - repo = 'https://github.com/${parts[0]}/${parts[1]}'; - } - } else { - return AttestationVerificationResult( - isValid: false, + identity = result.verifiedIdentity(); + issuer = result.verifiedIssuer(); + + final signer = GitHubWorkflowIdentity.tryParse(identity); + if (signer == null) { + final message = + 'Package attestation verification is currently only supported for ' + 'GitHub Actions workflows (signer identity: "$identity").'; + return AttestationVerificationResult.failure( + packageName: packageName, + packageVersion: packageVersion, + signerIdentity: identity, + oidcIssuer: issuer, + errors: [message], + ); + } + + final BuildProvenance provenance; + try { + provenance = BuildProvenance.parseBundle(bundleJson); + } on FormatException catch (e) { + return AttestationVerificationResult.failure( packageName: packageName, packageVersion: packageVersion, signerIdentity: identity, oidcIssuer: issuer, - errors: [ - 'Package attestation verification is currently only supported ' - 'for GitHub repositories (signer identity: "$identity").', - ], + errors: [e.message], ); } - final targetRepo = expectedRepository ?? pubspecRepository; - if (targetRepo != null && targetRepo.isNotEmpty) { - if (!targetRepo.contains('github.com')) { - return AttestationVerificationResult( - isValid: false, - packageName: packageName, - packageVersion: packageVersion, - repository: repo, - signerIdentity: identity, - oidcIssuer: issuer, - errors: [ - 'Package attestation verification is currently only supported ' - 'for GitHub repositories (got: "$targetRepo").', - ], - ); - } - if (repo == null || !_repositoriesMatch(repo, targetRepo)) { - final msg = - 'Attestation identity "$identity" does not match expected ' - 'repository "$targetRepo"'; - return AttestationVerificationResult( - isValid: false, - packageName: packageName, - packageVersion: packageVersion, - repository: repo, - signerIdentity: identity, - oidcIssuer: issuer, - errors: [msg], - ); - } + final errors = checkProvenancePolicy( + packageName: packageName, + packageVersion: packageVersion, + archiveSha256: sha256.convert(archiveBytes).toString(), + provenance: provenance, + signer: signer, + oidcIssuer: issuer, + declaredPackageName: declaredPackageName, + declaredPackageVersion: declaredPackageVersion, + expectedRepository: expectedRepository, + requireRepository: requireRepository, + trustedSignerWorkflows: _trustedSignerWorkflows, + requireTaggedSignerRef: _requireTaggedSignerRef, + ); + if (errors.isNotEmpty) { + return AttestationVerificationResult.failure( + packageName: packageName, + packageVersion: packageVersion, + signerIdentity: identity, + oidcIssuer: issuer, + errors: errors, + ); } return AttestationVerificationResult( isValid: true, packageName: packageName, packageVersion: packageVersion, - repository: repo ?? targetRepo, + provenance: ProvenanceInfo( + repository: provenance.sourceRepository.url, + ref: provenance.sourceRef, + commitSha: provenance.commitSha, + workflowPath: provenance.workflowPath, + ), signerIdentity: identity, oidcIssuer: issuer, ); + } on SigstoreError catch (e) { + // `package:sigstore` reports failures by throwing this enum - including + // an unsuccessful verification, which does not come back as a + // `SigstoreVerificationResult` with `isValid() == false`. + return AttestationVerificationResult.failure( + packageName: packageName, + packageVersion: packageVersion, + signerIdentity: identity, + oidcIssuer: issuer, + errors: [ + switch (e) { + SigstoreError.invalidBundle => + 'Attestation is not a valid ' + 'Sigstore bundle.', + SigstoreError.verificationFailed => + 'Attestation signature verification failed.', + SigstoreError.internalError => + 'Internal error while verifying the attestation.', + }, + ], + ); + // `package:sigstore` signals a missing `dart:ffi` by throwing an + // `UnsupportedError`. The catch-all below would handle it too, but + // catching it explicitly lets us explain to the user that the platform, + // rather than the attestation, is at fault. + // ignore: avoid_catching_errors + } on UnsupportedError catch (e) { + return AttestationVerificationResult.failure( + packageName: packageName, + packageVersion: packageVersion, + signerIdentity: identity, + oidcIssuer: issuer, + errors: [ + 'Attestations cannot be verified on this platform: ${e.message}', + ], + ); + // ignore: avoid_catches_without_on_clauses } catch (e) { - return AttestationVerificationResult( - isValid: false, + // Anything unexpected must fail verification, never pass it. + return AttestationVerificationResult.failure( packageName: packageName, packageVersion: packageVersion, + signerIdentity: identity, + oidcIssuer: issuer, errors: [e.toString()], ); } } +} + +/// Checks a cryptographically verified attestation against pub's policy, and +/// returns the list of violations - empty if the attestation is acceptable. +/// +/// [archiveSha256] is the lowercase hex-encoded sha256 of the archive, and +/// [signer] the identity from the signing certificate. Both must come from +/// verified material. +/// +/// This is separate from [PubAttestationVerifier.verify] so that the policy can +/// be exercised without doing (or faking) cryptographic verification. +List checkProvenancePolicy({ + required String packageName, + required Version packageVersion, + required String archiveSha256, + required BuildProvenance provenance, + required GitHubWorkflowIdentity signer, + String? oidcIssuer, + String? declaredPackageName, + Version? declaredPackageVersion, + String? expectedRepository, + bool requireRepository = true, + Set trustedSignerWorkflows = defaultTrustedSignerWorkflows, + bool requireTaggedSignerRef = false, +}) { + final errors = []; + + if (oidcIssuer != null && oidcIssuer != githubActionsOidcIssuer) { + errors.add( + 'Attestation was issued by "$oidcIssuer", ' + 'expected "$githubActionsOidcIssuer".', + ); + } - static bool _repositoriesMatch(String a, String b) { - final normA = a - .trim() - .toLowerCase() - .replaceAll(RegExp(r'\.git$'), '') - .replaceAll(RegExp(r'/+$'), ''); - final normB = b - .trim() - .toLowerCase() - .replaceAll(RegExp(r'\.git$'), '') - .replaceAll(RegExp(r'/+$'), ''); - return normA == normB || normA.endsWith(normB) || normB.endsWith(normA); + // Who signed? Only a small set of workflows may sign packages for pub.dev. + final trusted = trustedSignerWorkflows.map((w) => w.toLowerCase()).toSet(); + if (!trusted.contains(signer.workflow.toLowerCase())) { + errors.add( + 'Attestation was signed by "${signer.workflow}", which is not a ' + 'workflow trusted to publish packages. Trusted workflows: ' + '${(trustedSignerWorkflows.toList()..sort()).join(', ')}.', + ); + } else if (requireTaggedSignerRef && !signer.ref.startsWith('refs/tags/')) { + // TODO(sigurdm): Pin the signing workflow by the `job_workflow_sha` + // certificate extension (OID 1.3.6.1.4.1.57264.1.20) once + // `package:sigstore` exposes it. A ref is mutable, a sha is not. + errors.add( + 'Attestation was signed by "${signer.workflow}" at "${signer.ref}", ' + 'but only tagged releases of the publishing workflow are trusted.', + ); + } else if (!signer.ref.startsWith('refs/tags/') && + !signer.ref.startsWith('refs/heads/')) { + errors.add( + 'Attestation was signed by "${signer.workflow}" at "${signer.ref}", ' + 'which is not a branch or tag ref.', + ); } + final builder = provenance.builder; + if (builder != null && + builder.workflow.toLowerCase() != signer.workflow.toLowerCase()) { + errors.add( + 'Attestation states it was built by "${builder.workflow}" but was ' + 'signed by "${signer.workflow}".', + ); + } + + // What was signed? Without this an attestation for one package could be + // served for another - all packages share the same signer identity. + // `dart-lang/setup-dart` names the archive `package.tar.gz`, so the package + // name and version are bound through the `pubspec.yaml` inside the archive + // (covered by `archiveSha256`). + final expectedSubject = '$packageName-$packageVersion.tar.gz'; + final subject = + provenance.subjects + .where((s) => s.name == expectedSubject || s.name == 'package.tar.gz') + .firstOrNull; + if (subject == null) { + errors.add( + 'Attestation is for ' + '${provenance.subjects.map((s) => '"${s.name}"').join(', ')}, ' + 'not for "$expectedSubject".', + ); + } else if (subject.sha256 != archiveSha256.toLowerCase()) { + errors.add( + 'Attestation is for an archive with sha256 "${subject.sha256}", ' + 'but the downloaded archive has sha256 "$archiveSha256".', + ); + } + + if (declaredPackageName != null && declaredPackageName != packageName) { + errors.add( + 'The pubspec.yaml of the attested archive declares `name: ' + '$declaredPackageName`, expected `$packageName`.', + ); + } + if (declaredPackageVersion != null && + declaredPackageVersion != packageVersion) { + errors.add( + 'The pubspec.yaml of the attested archive declares `version: ' + '$declaredPackageVersion`, expected `$packageVersion`.', + ); + } + + // Where was it built? This is the binding the whole feature rests on. + if (expectedRepository == null || expectedRepository.trim().isEmpty) { + if (requireRepository) { + errors.add( + 'The pubspec.yaml of $packageName $packageVersion does not have a ' + '`repository` field, so the attestation cannot be bound to the ' + 'repository it claims to come from ' + '("${provenance.sourceRepository.url}").', + ); + } + } else { + final expected = GitHubRepository.tryParse(expectedRepository); + if (expected == null) { + errors.add( + 'Package attestation verification is currently only supported for ' + 'GitHub repositories, but the pubspec.yaml of $packageName ' + '$packageVersion declares `repository: $expectedRepository`.', + ); + } else if (expected != provenance.sourceRepository) { + errors.add( + 'Attestation states the package was built from ' + '"${provenance.sourceRepository.url}", but the pubspec.yaml of ' + '$packageName $packageVersion declares `repository: ' + '$expectedRepository`.', + ); + } + } + + return errors; } diff --git a/lib/src/source/hosted.dart b/lib/src/source/hosted.dart index 7d33272d0dc..02cc2c01e85 100644 --- a/lib/src/source/hosted.dart +++ b/lib/src/source/hosted.dart @@ -449,7 +449,11 @@ class HostedSource extends CachedSource { isRetracted: retracted, advisoriesUpdated: advisoriesDate, ); - final slsaLevel = map['slsaLevel'] as int?; + final slsaLevelField = map['slsa_level']; + if (slsaLevelField != null && slsaLevelField is! int) { + throw FormatException('slsa_level must be an int', map); + } + final slsaLevel = slsaLevelField as int?; return HostedVersionInfo( pubspec.version, pubspec, @@ -1429,6 +1433,51 @@ class HostedSource extends CachedSource { final path = provenancePath(id, cache); ensureDir(p.dirname(path)); writeTextFile(path, provenance); + // Also remember, independently of the version, that this package was + // attested at least once. See [packageProvenancePath]. + final packagePath = packageProvenancePath( + id.description.description as HostedDescription, + cache, + ); + ensureDir(p.dirname(packagePath)); + writeTextFile(packagePath, provenance); + } + + /// Next to the per-version provenance files there is a per-package file + /// recording the repository the package was last attested from. + /// + /// Whether an archive is verified must not be decided by the package + /// repository alone: a compromised repository could simply stop reporting + /// `slsa_level` for a package. Once we have seen an attestation for any + /// version of a package, we keep requiring one. + /// + /// Package names cannot contain `-`, so this never collides with the + /// `-.provenance` files. + String packageProvenancePath( + HostedDescription description, + SystemCache cache, + ) { + return p.join( + cache.rootDir, + 'hosted-provenance', + _urlToDirectory(description.url), + '${description.packageName}.provenance', + ); + } + + /// Loads the repository of the last attestation seen for the package + /// described by [description], or `null` if it was never attested. + String? packageProvenanceFromCache( + HostedDescription description, + SystemCache cache, + ) { + try { + final text = + readTextFile(packageProvenancePath(description, cache)).trim(); + return text.isEmpty ? null : text; + } on io.IOException { + return null; + } } /// Re-downloads all packages that have been previously downloaded into the @@ -1673,7 +1722,17 @@ class HostedSource extends CachedSource { } Uint8List? contentHash; - String? verifiedProvenance; + String? attestationJson; + + // The repository we have previously seen an attestation from for this + // package, if any. + final previousProvenance = packageProvenanceFromCache(description, cache); + // Attestations are fetched when the repository says there is one, but also + // when we have seen one before: the repository must not be able to skip + // verification by simply not reporting `slsa_level`. + final expectAttestation = + versionInfo.slsaLevel != null || previousProvenance != null; + final archiveUrl = versionInfo.archiveUrl; log.io('Get package from $archiveUrl.'); log.fine('Downloading ${log.bold(id.name)} ${id.version}...'); @@ -1708,8 +1767,9 @@ This indicates a problem on the package repository: `${description.url}`. See $contentHashesDocumentationUrl. '''); } - contentHash = Uint8List.fromList(actualHash.bytes); - writeHash(id, cache, contentHash!); + final hash = Uint8List.fromList(actualHash.bytes); + contentHash = hash; + writeHash(id, cache, hash); } // It is important that we do not compare against id.description.sha256, @@ -1753,58 +1813,28 @@ See $contentHashesDocumentationUrl. // ways around this, and we might revisit this later. await createFileFromStream(stream, archivePath); - // Fetch and verify Sigstore attestation if available - if (versionInfo.slsaLevel != null) { + // Fetch the Sigstore attestation. It is verified below, once the + // archive has been extracted and we can read the `repository` + // field of the pubspec it contains. + if (expectAttestation) { final attestationUri = Uri.parse(description.url).resolve( 'api/packages/${id.name}/versions/${id.version}/attestation', ); try { final attRequest = http.Request('GET', attestationUri); - final attResponse = await client.fetch(attRequest); - if (attResponse.statusCode == 200) { - SigstoreBundle bundle; - try { - bundle = SigstoreBundle.fromJson(attResponse.body); - } catch (e) { - throw PackageIntegrityException(''' -Downloaded attestation for ${id.name}-${id.version} is malformed: $e - -This indicates a problem on the package repository: `${description.url}`. -'''); - } - final archiveBytes = readBinaryFile(archivePath); - final verifier = PubAttestationVerifier(cache: cache); - final result = verifier.verify( - packageName: id.name, - packageVersion: id.version, - archiveBytes: archiveBytes, - bundle: bundle, - ); - if (!result.isValid) { - throw PackageIntegrityException(''' -Downloaded archive for ${id.name}-${id.version} failed Sigstore attestation verification: -${result.errors.map((e) => ' * $e').join('\n')} - -This indicates a problem on the package repository: `${description.url}`. -'''); - } - verifiedProvenance = result.repository; - if (verifiedProvenance != null) { - writeProvenance(id, cache, verifiedProvenance!); - } - log.fine( - 'Verified Sigstore attestation for ' - '${id.name}-${id.version} ' - 'from repository ${result.repository}.', - ); + attestationJson = (await client.fetch(attRequest)).body; + } on PubHttpResponseException catch (e) { + if (e.response.statusCode != HttpStatus.notFound) { + // Do not swallow errors here: an unverified package must not + // be the result of a failed request. Intermittent failures + // are retried by [retryForHttp]. + rethrow; } - } on PackageIntegrityException { - rethrow; - } catch (e) { log.fine( - 'No attestation found or error fetching attestation for ' - '${id.name}-${id.version}: $e', + 'No attestation served for ${id.name}-${id.version} ' + 'at $attestationUri.', ); + attestationJson = null; } } }); @@ -1818,10 +1848,107 @@ This indicates a problem on the package repository: `${description.url}`. } on FormatException catch (e) { dataError('Failed to extract `$archivePath`: ${e.message}.'); } + + final verifiedProvenance = _verifyAttestation( + id, + description, + cache, + attestationJson: attestationJson, + archivePath: archivePath, + extractedPath: destPath, + slsaLevel: versionInfo.slsaLevel, + previousProvenance: previousProvenance, + ); + if (verifiedProvenance != null) { + writeProvenance(id, cache, verifiedProvenance); + } return (contentHash!, verifiedProvenance); }); } + /// Verifies [attestationJson] against the archive at [archivePath], which has + /// been extracted to [extractedPath], and returns the repository the package + /// was built from. + /// + /// Returns `null` if the package is not attested, and throws if it should + /// have been. + String? _verifyAttestation( + PackageId id, + HostedDescription description, + SystemCache cache, { + required String? attestationJson, + required String archivePath, + required String extractedPath, + required int? slsaLevel, + required String? previousProvenance, + }) { + AttestationVerificationResult? result; + if (attestationJson != null) { + // The `repository` of the version listing is served by the package + // repository and therefore not trustworthy. The pubspec inside the + // archive is covered by the attested digest. + final pubspecPath = p.join(extractedPath, 'pubspec.yaml'); + final Pubspec pubspec; + try { + pubspec = Pubspec.parse( + readTextFile(pubspecPath), + cache.sources, + containingDescription: ResolvedRootDescription.fromDir('.'), + ); + } on Exception catch (e) { + throw PackageIntegrityException(''' +Could not read the pubspec of the attested archive for ${id.name}-${id.version}: $e + +This indicates a problem on the package repository: `${description.url}`. +'''); + } + final declaredRepository = pubspec.fields['repository']?.toString(); + + result = PubAttestationVerifier(cache: cache).verify( + packageName: id.name, + packageVersion: id.version, + archiveBytes: readBinaryFile(archivePath), + bundleJson: attestationJson, + declaredPackageName: pubspec.name, + declaredPackageVersion: pubspec.version, + expectedRepository: declaredRepository, + ); + if (!result.isValid) { + throw PackageIntegrityException(''' +Downloaded archive for ${id.name}-${id.version} failed Sigstore attestation verification: +${result.errors.map((e) => ' * $e').join('\n')} + +This indicates a problem on the package repository: `${description.url}`. +'''); + } + log.fine( + 'Verified Sigstore attestation for ${id.name}-${id.version} ' + 'from repository ${result.repository}.', + ); + } else if (slsaLevel != null) { + throw PackageIntegrityException(''' +The package repository reports that ${id.name}-${id.version} has SLSA level $slsaLevel, +but served no attestation for it. + +This indicates a problem on the package repository: `${description.url}`. +'''); + } + + // Throws if a previously attested package is no longer attested, and warns + // if it changed repository. + ProvenancePolicy.enforcePolicy( + packageName: id.name, + version: id.version, + currentProvenance: result?.provenance, + previousLockedProvenance: + previousProvenance == null + ? null + : ProvenanceInfo(repository: previousProvenance), + onWarning: log.warning, + ); + return result?.repository; + } + /// Writes the contenthash for [id] in the cache. void writeHash(PackageId id, SystemCache cache, List bytes) { final path = hashPath(id, cache); @@ -2150,14 +2277,17 @@ class ResolvedHostedDescription extends ResolvedDescription { } @override - // We do not include the sha256 in the hashCode because of the equality - // semantics. - int get hashCode => Object.hash(description, provenance); + // We do not include the sha256 or the provenance in the hashCode because of + // the equality semantics: a `null` value compares equal to any other value, + // so including them would break the hashCode/== contract. + int get hashCode => description.hashCode; @override bool operator ==(Object other) { return other is ResolvedHostedDescription && other.description == description && + // A [provenance] of `null` means that we don't know the provenance + // yet. Therefore we have to assume it is equal to any known value. (provenance == null || other.provenance == null || provenance == other.provenance) && @@ -2175,6 +2305,10 @@ class ResolvedHostedDescription extends ResolvedDescription { provenance: provenance, ); + /// Returns a description with [newProvenance], if it is not `null`. + /// + /// A known provenance is never cleared: that would silently drop the + /// repository binding of an already verified package. ResolvedHostedDescription withProvenance(String? newProvenance) => ResolvedHostedDescription( description, diff --git a/test/get/hosted/attestation_verification_test.dart b/test/get/hosted/attestation_verification_test.dart index 2a910b97061..7626510a7a2 100644 --- a/test/get/hosted/attestation_verification_test.dart +++ b/test/get/hosted/attestation_verification_test.dart @@ -7,6 +7,7 @@ library; import 'dart:io'; +import 'package:path/path.dart' as p; import 'package:pub/src/exit_codes.dart'; import 'package:shelf/shelf.dart'; import 'package:test/test.dart'; @@ -16,6 +17,72 @@ import '../../sigstore/test_fixtures.dart'; import '../../test_pub.dart'; void main() { + test( + 'verifies a real signed package attestation and records provenance (helpful 0.1.5)', + () async { + final server = await servePackages(); + server.serve( + 'helpful', + '0.1.5', + slsaLevel: 2, + archiveBytes: helpfulArchiveBytes, + pubspec: {'repository': 'https://github.com/mosuem/helpful'}, + ); + server.handle('/api/packages/helpful/versions/0.1.5/attestation', ( + request, + ) { + return Response.ok( + helpfulBundleJson, + headers: {'content-type': 'application/json; charset="utf-8"'}, + ); + }); + + await d + .appDir( + dependencies: { + 'helpful': {'hosted': server.url, 'version': '^0.1.0'}, + }, + ) + .create(); + + await pubGet(); + + final cacheDir = server.pathInCache('helpful', '0.1.5'); + expect(Directory(cacheDir).existsSync(), isTrue); + + final versionProvenance = File( + p.join(server.provenanceCachingPath, 'helpful-0.1.5.provenance'), + ); + final packageProvenance = File( + p.join(server.provenanceCachingPath, 'helpful.provenance'), + ); + expect( + versionProvenance.readAsStringSync(), + 'https://github.com/mosuem/helpful', + ); + expect( + packageProvenance.readAsStringSync(), + 'https://github.com/mosuem/helpful', + ); + + final lockfile = + File(p.join(d.sandbox, appPath, 'pubspec.lock')).readAsStringSync(); + expect( + lockfile, + contains('provenance: "https://github.com/mosuem/helpful"'), + ); + + // Serving a newer unattested version of the same package must be + // rejected as a downgrade even if the lockfile is deleted. + File(p.join(d.sandbox, appPath, 'pubspec.lock')).deleteSync(); + server.serve('helpful', '0.1.6'); + await pubGet( + error: contains('Downgrading from a signed package'), + exitCode: DATA, + ); + }, + ); + test('proceeds when no attestation is served by repository', () async { final server = await servePackages(); server.serve('foo', '1.0.0'); @@ -60,4 +127,51 @@ void main() { ); }, ); + + test('pub get fails when an attestation is claimed but not served', () async { + // Reporting `slsa_level` and then not serving an attestation must not + // silently downgrade to an unverified install. + final server = await servePackages(); + server.serve('foo', '1.0.0', slsaLevel: 2); + + await d + .appDir( + dependencies: { + 'foo': {'hosted': server.url, 'version': '^1.0.0'}, + }, + ) + .create(); + + await pubGet(error: contains('served no attestation'), exitCode: TEMP_FAIL); + }); + + test( + 'pub get fails when a previously attested package is no longer attested', + () async { + // The repository does not report `slsa_level` for this version, but we + // have seen an attestation for this package before. A repository must + // not be able to turn verification off. + final server = await servePackages(); + server.serve('foo', '1.0.0'); + + await d + .appDir( + dependencies: { + 'foo': {'hosted': server.url, 'version': '^1.0.0'}, + }, + ) + .create(); + + final marker = File( + p.join(server.provenanceCachingPath, 'foo.provenance'), + ); + marker.parent.createSync(recursive: true); + marker.writeAsStringSync('https://github.com/dieter/helpful'); + + await pubGet( + error: contains('Downgrading from a signed package'), + exitCode: DATA, + ); + }, + ); } diff --git a/test/package_server.dart b/test/package_server.dart index b9cc0370ac4..0670cd78ecb 100644 --- a/test/package_server.dart +++ b/test/package_server.dart @@ -111,7 +111,7 @@ class PackageServer { 'archive_url': '${server.url}/packages/$name/versions/${version.version}.tar.gz', if (version.isRetracted) 'retracted': true, - if (version.slsaLevel != null) 'slsaLevel': version.slsaLevel, + if (version.slsaLevel != null) 'slsa_level': version.slsaLevel, if (version.sha256 != null || server.serveContentHashes) 'archive_sha256': version.sha256 ?? @@ -285,6 +285,9 @@ class PackageServer { String get hashesCachingPath => p.join(d.sandbox, cachePath, 'hosted-hashes', 'localhost%58$port'); + String get provenanceCachingPath => + p.join(d.sandbox, cachePath, 'hosted-provenance', 'localhost%58$port'); + /// A map from package names to the concrete packages to serve. final _packages = {}; @@ -301,6 +304,7 @@ class PackageServer { Map? deps, Map? pubspec, List? contents, + List? archiveBytes, String? sdk, Map>? headers, int? slsaLevel, @@ -320,7 +324,10 @@ class PackageServer { package.versions[version] = _ServedPackageVersion( pubspecFields, headers: headers, - contents: () => tarFromDescriptors(contents ?? []), + contents: + archiveBytes != null + ? () => Stream.value(archiveBytes) + : () => tarFromDescriptors(contents ?? []), slsaLevel: slsaLevel, ); } diff --git a/test/sigstore/policy_test.dart b/test/sigstore/policy_test.dart new file mode 100644 index 00000000000..addebecfd84 --- /dev/null +++ b/test/sigstore/policy_test.dart @@ -0,0 +1,174 @@ +// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +@TestOn('vm') +library; + +import 'package:pub/src/sigstore/provenance.dart'; +import 'package:pub/src/sigstore/verifier.dart'; +import 'package:pub_semver/pub_semver.dart'; +import 'package:test/test.dart'; + +import 'test_fixtures.dart'; + +const _trustedSigner = + 'https://github.com/dart-lang/setup-dart/.github/workflows/' + 'publish.yml@refs/tags/v2'; + +const _archiveSha256 = + '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08'; + +/// Runs the policy for `helpful 1.0.0`, published from `dieter/helpful`. +List check({ + String packageName = 'helpful', + String version = '1.0.0', + String archiveSha256 = _archiveSha256, + String subjectName = 'helpful-1.0.0.tar.gz', + String subjectSha256 = _archiveSha256, + String repository = 'https://github.com/dieter/helpful', + String? builderId = _trustedSigner, + String signerIdentity = _trustedSigner, + String? oidcIssuer = githubActionsOidcIssuer, + String? expectedRepository = 'https://github.com/dieter/helpful', + bool requireRepository = true, + bool requireTaggedSignerRef = true, +}) { + return checkProvenancePolicy( + packageName: packageName, + packageVersion: Version.parse(version), + archiveSha256: archiveSha256, + provenance: BuildProvenance.parseBundle( + buildProvenanceBundleJson( + subjectName: subjectName, + subjectSha256: subjectSha256, + repository: repository, + builderId: builderId, + ), + ), + signer: GitHubWorkflowIdentity.tryParse(signerIdentity)!, + oidcIssuer: oidcIssuer, + expectedRepository: expectedRepository, + requireRepository: requireRepository, + requireTaggedSignerRef: requireTaggedSignerRef, + ); +} + +void main() { + test( + 'accepts a package built by the trusted workflow from its repository', + () { + expect(check(), isEmpty); + }, + ); + + group('signer', () { + test('rejects a signature from an untrusted workflow', () { + // This is the case the client used to accept: a cryptographically valid + // bundle from any GitHub Actions workflow in any repository. + final errors = check( + signerIdentity: + 'https://github.com/evil/evil/.github/workflows/sign.yml@' + 'refs/tags/v1', + builderId: + 'https://github.com/evil/evil/.github/workflows/sign.yml@' + 'refs/tags/v1', + ); + expect(errors, hasLength(1)); + expect(errors.single, contains('not a workflow trusted to publish')); + }); + + test('rejects the trusted workflow at a mutable ref', () { + final errors = check( + signerIdentity: + 'https://github.com/dart-lang/setup-dart/.github/workflows/' + 'publish.yml@refs/heads/main', + builderId: + 'https://github.com/dart-lang/setup-dart/.github/workflows/' + 'publish.yml@refs/heads/main', + ); + expect(errors.single, contains('only tagged releases')); + }); + + test('rejects a builder that disagrees with the signer', () { + final errors = check( + builderId: + 'https://github.com/evil/evil/.github/workflows/sign.yml@' + 'refs/tags/v1', + ); + expect(errors.single, contains('but was signed by')); + }); + + test('rejects a non-GitHub issuer', () { + final errors = check(oidcIssuer: 'https://accounts.google.com'); + expect(errors.single, contains('was issued by')); + }); + }); + + group('subject', () { + test('rejects an attestation for another package', () { + // All packages are signed by the same workflow, so without this check a + // compromised repository could serve the archive and attestation of one + // package under the name of another. + final errors = check(subjectName: 'evil-1.0.0.tar.gz'); + expect(errors.single, contains('not for "helpful-1.0.0.tar.gz"')); + }); + + test('rejects an attestation for another version', () { + final errors = check(subjectName: 'helpful-1.0.1.tar.gz'); + expect(errors.single, contains('not for "helpful-1.0.0.tar.gz"')); + }); + + test('rejects an attestation for other bytes', () { + final errors = check(subjectSha256: 'a' * 64); + expect(errors.single, contains('but the downloaded archive has sha256')); + }); + }); + + group('repository', () { + test('rejects a build from another repository', () { + final errors = check(repository: 'https://github.com/evil/helpful'); + expect(errors.single, contains('was built from')); + }); + + test('rejects a repository that only matches by suffix', () { + final errors = check( + repository: 'https://github.com/evil-dieter/helpful', + ); + expect(errors.single, contains('was built from')); + }); + + test('accepts a monorepo subdirectory url', () { + expect( + check( + repository: 'https://github.com/dart-lang/tools', + expectedRepository: + 'https://github.com/dart-lang/tools/tree/main/pkgs/helpful', + ), + isEmpty, + ); + }); + + test('accepts a differently spelled url for the same repository', () { + expect( + check(expectedRepository: 'https://github.com/Dieter/helpful.git'), + isEmpty, + ); + }); + + test('rejects a package that does not declare a repository', () { + // Without a repository in the pubspec the attestation is unbound: it + // only says that some trusted workflow built these bytes. + final errors = check(expectedRepository: null); + expect(errors.single, contains('does not have a `repository` field')); + expect(check(expectedRepository: ' '), hasLength(1)); + }); + + test('rejects a non-GitHub repository in the pubspec', () { + final errors = check( + expectedRepository: 'https://gitlab.com/dieter/helpful', + ); + expect(errors.single, contains('only supported for GitHub')); + }); + }); +} diff --git a/test/sigstore/provenance_test.dart b/test/sigstore/provenance_test.dart new file mode 100644 index 00000000000..6cf42286c58 --- /dev/null +++ b/test/sigstore/provenance_test.dart @@ -0,0 +1,196 @@ +// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +@TestOn('vm') +library; + +import 'package:pub/src/sigstore/provenance.dart'; +import 'package:test/test.dart'; + +import 'test_fixtures.dart'; + +void main() { + group('GitHubRepository.tryParse', () { + void expectRepo(String source, String? slug) { + expect( + GitHubRepository.tryParse(source)?.slug, + slug, + reason: 'parsing "$source"', + ); + } + + test('accepts the forms found in pubspecs and provenance', () { + expectRepo('https://github.com/dieter/helpful', 'dieter/helpful'); + expectRepo('https://github.com/dieter/helpful/', 'dieter/helpful'); + expectRepo('https://github.com/dieter/helpful.git', 'dieter/helpful'); + expectRepo('http://github.com/dieter/helpful', 'dieter/helpful'); + expectRepo('https://www.github.com/dieter/helpful', 'dieter/helpful'); + expectRepo(' https://github.com/dieter/helpful ', 'dieter/helpful'); + expectRepo( + 'git+https://github.com/dieter/helpful@refs/tags/v1.0.0', + 'dieter/helpful', + ); + }); + + test('ignores the subdirectory of a monorepo package', () { + expectRepo( + 'https://github.com/dart-lang/tools/tree/main/pkgs/graphs', + 'dart-lang/tools', + ); + }); + + test('rejects everything that is not a GitHub repository', () { + expectRepo('https://gitlab.com/dieter/helpful', null); + expectRepo('https://example.com/dieter/helpful', null); + // A suffix check on the host would accept this one. + expectRepo('https://github.com.evil.example/dieter/helpful', null); + expectRepo('https://notgithub.com/dieter/helpful', null); + expectRepo('https://github.com/dieter', null); + expectRepo('https://github.com/', null); + expectRepo('ftp://github.com/dieter/helpful', null); + expectRepo('dieter/helpful', null); + expectRepo('', null); + }); + + test('compares case-insensitively but does not match by suffix', () { + final helpful = GitHubRepository.tryParse( + 'https://github.com/dieter/helpful', + ); + expect( + helpful, + GitHubRepository.tryParse('https://github.com/Dieter/Helpful.git'), + ); + // `endsWith`-style matching would consider these equal. + expect( + helpful, + isNot( + GitHubRepository.tryParse('https://github.com/evil-dieter/helpful'), + ), + ); + expect( + helpful, + isNot(GitHubRepository.tryParse('https://github.com/evil/helpful')), + ); + }); + }); + + group('GitHubWorkflowIdentity.tryParse', () { + test('splits repository, path and ref', () { + final identity = + GitHubWorkflowIdentity.tryParse( + 'https://github.com/dart-lang/setup-dart/.github/workflows/' + 'publish.yml@refs/tags/v2', + )!; + expect(identity.repository.slug, 'dart-lang/setup-dart'); + expect(identity.path, '.github/workflows/publish.yml'); + expect(identity.ref, 'refs/tags/v2'); + expect( + identity.workflow, + 'dart-lang/setup-dart/.github/workflows/publish.yml', + ); + }); + + test('rejects identities without a workflow path or ref', () { + expect( + GitHubWorkflowIdentity.tryParse( + 'https://github.com/dart-lang/setup-dart@refs/tags/v2', + ), + isNull, + ); + expect( + GitHubWorkflowIdentity.tryParse( + 'https://github.com/dart-lang/setup-dart/.github/workflows/p.yml', + ), + isNull, + ); + expect( + GitHubWorkflowIdentity.tryParse('mailto:someone@example.com'), + isNull, + ); + }); + }); + + group('BuildProvenance.parseBundle', () { + test('extracts the repository, ref, commit and subjects', () { + final provenance = BuildProvenance.parseBundle( + buildProvenanceBundleJson( + subjectName: 'helpful-0.1.2.tar.gz', + subjectSha256: 'abc123', + repository: 'https://github.com/dieter/helpful', + ref: 'refs/tags/v0.1.2', + commitSha: 'deadbeef', + ), + ); + expect(provenance.sourceRepository.slug, 'dieter/helpful'); + expect(provenance.sourceRef, 'refs/tags/v0.1.2'); + expect(provenance.commitSha, 'deadbeef'); + expect(provenance.subjects.single.name, 'helpful-0.1.2.tar.gz'); + expect(provenance.subjects.single.sha256, 'abc123'); + expect( + provenance.builder!.workflow, + 'dart-lang/setup-dart/.github/workflows/publish.yml', + ); + }); + + test('rejects a bundle without a DSSE envelope', () { + // The bundle of a plain artifact signature carries no statement at all - + // there is nothing to bind it to a package. + expect( + () => BuildProvenance.parseBundle(sampleBundleJson), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('carries no build provenance'), + ), + ), + ); + }); + + test('rejects a statement that is not SLSA build provenance', () { + expect( + () => BuildProvenance.parseBundle( + buildProvenanceBundleJson( + predicateType: 'https://slsa.dev/verification_summary/v1', + ), + ), + throwsA(isA()), + ); + expect( + () => BuildProvenance.parseBundle( + buildProvenanceBundleJson(payloadType: 'application/json'), + ), + throwsA(isA()), + ); + }); + + test('rejects a build from a non-GitHub repository', () { + expect( + () => BuildProvenance.parseBundle( + buildProvenanceBundleJson( + repository: 'https://gitlab.com/dieter/helpful', + ), + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('not a GitHub repository'), + ), + ), + ); + }); + + test('rejects malformed input', () { + expect( + () => BuildProvenance.parseBundle('not json'), + throwsA(isA()), + ); + expect( + () => BuildProvenance.parseBundle('{"dsseEnvelope": 42}'), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/sigstore/test_fixtures.dart b/test/sigstore/test_fixtures.dart index f537d135542..c60bedb98a2 100644 --- a/test/sigstore/test_fixtures.dart +++ b/test/sigstore/test_fixtures.dart @@ -16,3 +16,78 @@ final Uint8List sampleArtifactBytes = Uint8List.fromList( const String sampleBundleJson = r'''{"mediaType": "application/vnd.dev.sigstore.bundle+json;version=0.3", "verificationMaterial": {"certificate": {"rawBytes": "MIIIMTCCB7egAwIBAgIUaL/tsmQTHk21mt1Uuk+w7avDBz4wCgYIKoZIzj0EAwMwNzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwHhcNMjQwMzE5MTcyNjI2WhcNMjQwMzE5MTczNjI2WjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE22S1j/NkEXzBPQAuamHXLpwx+RPnnzZQl/pkEZ8xorvKnzujCS1mVTBo9kBxmYWo2DHtyVyfgnuOqVTzLYmho6OCBtYwggbSMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQUFv1SCziEKN2rRyrjeVlFbSLg1/QwHwYDVR0jBBgwFoAU39Ppz1YkEZb5qNjpKFWixi4YZD8wgaUGA1UdEQEB/wSBmjCBl4aBlGh0dHBzOi8vZ2l0aHViLmNvbS9zaWdzdG9yZS1jb25mb3JtYW5jZS9leHRyZW1lbHktZGFuZ2Vyb3VzLXB1YmxpYy1vaWRjLWJlYWNvbi8uZ2l0aHViL3dvcmtmbG93cy9leHRyZW1lbHktZGFuZ2Vyb3VzLW9pZGMtYmVhY29uLnltbEByZWZzL2hlYWRzL21haW4wOQYKKwYBBAGDvzABAQQraHR0cHM6Ly90b2tlbi5hY3Rpb25zLmdpdGh1YnVzZXJjb250ZW50LmNvbTAfBgorBgEEAYO/MAECBBF3b3JrZmxvd19kaXNwYXRjaDA2BgorBgEEAYO/MAEDBChjN2IzZGZiMzM1ZjA1MWUxYzg2YmRhNGM3MTZmYWM5N2RmNjJhZDgxMC0GCisGAQQBg78wAQQEH0V4dHJlbWVseSBkYW5nZXJvdXMgT0lEQyBiZWFjb24wSQYKKwYBBAGDvzABBQQ7c2lnc3RvcmUtY29uZm9ybWFuY2UvZXh0cmVtZWx5LWRhbmdlcm91cy1wdWJsaWMtb2lkYy1iZWFjb24wHQYKKwYBBAGDvzABBgQPcmVmcy9oZWFkcy9tYWluMDsGCisGAQQBg78wAQgELQwraHR0cHM6Ly90b2tlbi5hY3Rpb25zLmdpdGh1YnVzZXJjb250ZW50LmNvbTCBpgYKKwYBBAGDvzABCQSBlwyBlGh0dHBzOi8vZ2l0aHViLmNvbS9zaWdzdG9yZS1jb25mb3JtYW5jZS9leHRyZW1lbHktZGFuZ2Vyb3VzLXB1YmxpYy1vaWRjLWJlYWNvbi8uZ2l0aHViL3dvcmtmbG93cy9leHRyZW1lbHktZGFuZ2Vyb3VzLW9pZGMtYmVhY29uLnltbEByZWZzL2hlYWRzL21haW4wOAYKKwYBBAGDvzABCgQqDChjN2IzZGZiMzM1ZjA1MWUxYzg2YmRhNGM3MTZmYWM5N2RmNjJhZDgxMB0GCisGAQQBg78wAQsEDwwNZ2l0aHViLWhvc3RlZDBeBgorBgEEAYO/MAEMBFAMTmh0dHBzOi8vZ2l0aHViLmNvbS9zaWdzdG9yZS1jb25mb3JtYW5jZS9leHRyZW1lbHktZGFuZ2Vyb3VzLXB1YmxpYy1vaWRjLWJlYWNvbjA4BgorBgEEAYO/MAENBCoMKGM3YjNkZmIzMzVmMDUxZTFjODZiZGE0YzcxNmZhYzk3ZGY2MmFkODEwHwYKKwYBBAGDvzABDgQRDA9yZWZzL2hlYWRzL21haW4wGQYKKwYBBAGDvzABDwQLDAk2MzI1OTY4OTcwNwYKKwYBBAGDvzABEAQpDCdodHRwczovL2dpdGh1Yi5jb20vc2lnc3RvcmUtY29uZm9ybWFuY2UwGQYKKwYBBAGDvzABEQQLDAkxMzE4MDQ1NjMwgaYGCisGAQQBg78wARIEgZcMgZRodHRwczovL2dpdGh1Yi5jb20vc2lnc3RvcmUtY29uZm9ybWFuY2UvZXh0cmVtZWx5LWRhbmdlcm91cy1wdWJsaWMtb2lkYy1iZWFjb24vLmdpdGh1Yi93b3JrZmxvd3MvZXh0cmVtZWx5LWRhbmdlcm91cy1vaWRjLWJlYWNvbi55bWxAcmVmcy9oZWFkcy9tYWluMDgGCisGAQQBg78wARMEKgwoYzdiM2RmYjMzNWYwNTFlMWM4NmJkYTRjNzE2ZmFjOTdkZjYyYWQ4MTAhBgorBgEEAYO/MAEUBBMMEXdvcmtmbG93X2Rpc3BhdGNoMIGBBgorBgEEAYO/MAEVBHMMcWh0dHBzOi8vZ2l0aHViLmNvbS9zaWdzdG9yZS1jb25mb3JtYW5jZS9leHRyZW1lbHktZGFuZ2Vyb3VzLXB1YmxpYy1vaWRjLWJlYWNvbi9hY3Rpb25zL3J1bnMvODM0NzQ4MTYyOC9hdHRlbXB0cy8xMBYGCisGAQQBg78wARYECAwGcHVibGljMIGKBgorBgEEAdZ5AgQCBHwEegB4AHYA3T0wasbHETJjGR4cmWc3AqJKXrjePK3/h4pygC8p7o4AAAGOV8AHpgAABAMARzBFAiBFeMbpFarlPwb0naTr4mjWDvXApOd9ORqOk36Brt9SmwIhAJJvjor+DXUXr7S3Vm9jVFT3CL0BxcKGj86m5mYzQvubMAoGCCqGSM49BAMDA2gAMGUCMA8lTixdS4iN9mAUduObcSJmhZLyvK7zaX05DLEDCgPWxDHk+JBZUKYRIuHHgwFnOwIxALMamo9dfENMzRgNCzYfp/y+rSOhVjXXE9mCn6BuJETlpRDfGvxUg/5LF9f4lYqozA=="}, "tlogEntries": [{"logIndex": "79571823", "logId": {"keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="}, "kindVersion": {"kind": "hashedrekord", "version": "0.0.1"}, "integratedTime": "1710869186", "inclusionPromise": {"signedEntryTimestamp": "MEYCIQDMNM49CNrcrpuvB9G3likdSse0miAkY0ILCqzRGP5ZJQIhAKnSS9GUSFVCar1+Sq3qoRtJIJ8x9tqRnQ8kuS1ojtTH"}, "inclusionProof": {"logIndex": "75408392", "rootHash": "Fnnj13Uu1jdksPc4HZLapKX329dVlD5+MGNsiqBq1XM=", "treeSize": "75408393", "hashes": ["1J7hRIEGvYdAyzEs+GhAE9L+38oHye3BhalgoQRZoo4=", "W/OUCkh/lqDDwbBkZgP7eTV/wx4WifD1wtfRLbavfxI=", "9wya2BEhfLGDfDRVN46OU2RXkozWCM1Z4qMu6SPiWoY=", "ZRs3lKAIlu0t0GtLupAcOu1y20nOaOshSKosWAqFO+w=", "BGqH+LzVuhuqCLiUvBJaB2hlsvtu2a15qq1WGw6mG44=", "OeS7D4kPES7ChE7kWSEmhbAMqBcKVj/z8/afMK4Y3pI=", "JtjqvAqFyXXYjWlZfDzElHpEzdBjsz1LmGFJuYx0kTU=", "s/ZIVcfcD4/nuZwUtQf4ydGsIAkGTPTzk3b0zhUC95k=", "YU1jZY/fp5tJdGF/i+/7ez8107O4/lOUp7acMPFEaOA=", "7Z18YLBAvejEV4nJHIKoks/xlijnhR005qTW2w4QtHg=", "98enzMaC+x5oCMvIZQA5z8vu2apDMCFvE/935NfuPw8="], "checkpoint": {"envelope": "rekor.sigstore.dev - 2605736670972794746\n75408393\nFnnj13Uu1jdksPc4HZLapKX329dVlD5+MGNsiqBq1XM=\n\n— rekor.sigstore.dev wNI9ajBFAiBTyiBM9WtyOTgohje6QZ5rFGJUdMq7Wk3A6oThE98SUgIhAMvxDwa7FyqRqg+YV3rdPPrfS23w19iK+piMSGVOmP5w\n"}}, "canonicalizedBody": "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaGFzaGVkcmVrb3JkIiwic3BlYyI6eyJkYXRhIjp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiJhMGNmYzcxMjcxZDZlMjc4ZTU3Y2QzMzJmZjk1N2MzZjcwNDNmZGRhMzU0YzRjYmIxOTBhMzBkNTZlZmEwMWJmIn19LCJzaWduYXR1cmUiOnsiY29udGVudCI6Ik1FVUNJQ1lGcS80YlRFZGx1cmdxVnVObXdDY0lXdTNOS09DZ3ZlV0FKQmllekowdUFpRUEyaTdVMTgrYVJwRnhMWWtzcjVIS0JRUXkwOHpFMDUwV0ljMFJ6S3VuRElBPSIsInB1YmxpY0tleSI6eyJjb250ZW50IjoiTFMwdExTMUNSVWRKVGlCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2sxSlNVbE5WRU5EUWpkbFowRjNTVUpCWjBsVllVd3ZkSE50VVZSSWF6SXhiWFF4VlhWckszYzNZWFpFUW5vMGQwTm5XVWxMYjFwSmVtb3dSVUYzVFhjS1RucEZWazFDVFVkQk1WVkZRMmhOVFdNeWJHNWpNMUoyWTIxVmRWcEhWakpOVWpSM1NFRlpSRlpSVVVSRmVGWjZZVmRrZW1SSE9YbGFVekZ3WW01U2JBcGpiVEZzV2tkc2FHUkhWWGRJYUdOT1RXcFJkMDE2UlRWTlZHTjVUbXBKTWxkb1kwNU5hbEYzVFhwRk5VMVVZM3BPYWtreVYycEJRVTFHYTNkRmQxbElDa3R2V2tsNmFqQkRRVkZaU1V0dldrbDZhakJFUVZGalJGRm5RVVV5TWxNeGFpOU9hMFZZZWtKUVVVRjFZVzFJV0V4d2QzZ3JVbEJ1Ym5wYVVXd3ZjR3NLUlZvNGVHOXlka3R1ZW5WcVExTXhiVlpVUW04NWEwSjRiVmxYYnpKRVNIUjVWbmxtWjI1MVQzRldWSHBNV1cxb2J6WlBRMEowV1hkbloySlRUVUUwUndwQk1WVmtSSGRGUWk5M1VVVkJkMGxJWjBSQlZFSm5UbFpJVTFWRlJFUkJTMEpuWjNKQ1owVkdRbEZqUkVGNlFXUkNaMDVXU0ZFMFJVWm5VVlZHZGpGVENrTjZhVVZMVGpKeVVubHlhbVZXYkVaaVUweG5NUzlSZDBoM1dVUldVakJxUWtKbmQwWnZRVlV6T1ZCd2VqRlphMFZhWWpWeFRtcHdTMFpYYVhocE5Ga0tXa1E0ZDJkaFZVZEJNVlZrUlZGRlFpOTNVMEp0YWtOQ2JEUmhRbXhIYURCa1NFSjZUMms0ZGxveWJEQmhTRlpwVEcxT2RtSlRPWHBoVjJSNlpFYzVlUXBhVXpGcVlqSTFiV0l6U25SWlZ6VnFXbE01YkdWSVVubGFWekZzWWtocmRGcEhSblZhTWxaNVlqTldla3hZUWpGWmJYaHdXWGt4ZG1GWFVtcE1WMHBzQ2xsWFRuWmlhVGgxV2pKc01HRklWbWxNTTJSMlkyMTBiV0pIT1ROamVUbHNaVWhTZVZwWE1XeGlTR3QwV2tkR2RWb3lWbmxpTTFaNlRGYzVjRnBIVFhRS1dXMVdhRmt5T1hWTWJteDBZa1ZDZVZwWFducE1NbWhzV1ZkU2Vrd3lNV2hoVnpSM1QxRlpTMHQzV1VKQ1FVZEVkbnBCUWtGUlVYSmhTRkl3WTBoTk5ncE1lVGt3WWpKMGJHSnBOV2haTTFKd1lqSTFla3h0WkhCa1IyZ3hXVzVXZWxwWVNtcGlNalV3V2xjMU1FeHRUblppVkVGbVFtZHZja0puUlVWQldVOHZDazFCUlVOQ1FrWXpZak5LY2xwdGVIWmtNVGxyWVZoT2QxbFlVbXBoUkVFeVFtZHZja0puUlVWQldVOHZUVUZGUkVKRGFHcE9Na2w2V2tkYWFVMTZUVEVLV21wQk1VMVhWWGhaZW1jeVdXMVNhRTVIVFROTlZGcHRXVmROTlU0eVVtMU9ha3BvV2tSbmVFMURNRWREYVhOSFFWRlJRbWMzT0hkQlVWRkZTREJXTkFwa1NFcHNZbGRXYzJWVFFtdFpWelZ1V2xoS2RtUllUV2RVTUd4RlVYbENhVnBYUm1waU1qUjNVMUZaUzB0M1dVSkNRVWRFZG5wQlFrSlJVVGRqTW14dUNtTXpVblpqYlZWMFdUSTVkVnB0T1hsaVYwWjFXVEpWZGxwWWFEQmpiVlowV2xkNE5VeFhVbWhpYldSc1kyMDVNV041TVhka1YwcHpZVmROZEdJeWJHc0tXWGt4YVZwWFJtcGlNalIzU0ZGWlMwdDNXVUpDUVVkRWRucEJRa0puVVZCamJWWnRZM2s1YjFwWFJtdGplVGwwV1Zkc2RVMUVjMGREYVhOSFFWRlJRZ3BuTnpoM1FWRm5SVXhSZDNKaFNGSXdZMGhOTmt4NU9UQmlNblJzWW1rMWFGa3pVbkJpTWpWNlRHMWtjR1JIYURGWmJsWjZXbGhLYW1JeU5UQmFWelV3Q2t4dFRuWmlWRU5DY0dkWlMwdDNXVUpDUVVkRWRucEJRa05SVTBKc2QzbENiRWRvTUdSSVFucFBhVGgyV2pKc01HRklWbWxNYlU1MllsTTVlbUZYWkhvS1pFYzVlVnBUTVdwaU1qVnRZak5LZEZsWE5XcGFVemxzWlVoU2VWcFhNV3hpU0d0MFdrZEdkVm95Vm5saU0xWjZURmhDTVZsdGVIQlplVEYyWVZkU2FncE1WMHBzV1ZkT2RtSnBPSFZhTW13d1lVaFdhVXd6WkhaamJYUnRZa2M1TTJONU9XeGxTRko1V2xjeGJHSklhM1JhUjBaMVdqSldlV0l6Vm5wTVZ6bHdDbHBIVFhSWmJWWm9XVEk1ZFV4dWJIUmlSVUo1V2xkYWVrd3lhR3haVjFKNlRESXhhR0ZYTkhkUFFWbExTM2RaUWtKQlIwUjJla0ZDUTJkUmNVUkRhR29LVGpKSmVscEhXbWxOZWsweFdtcEJNVTFYVlhoWmVtY3lXVzFTYUU1SFRUTk5WRnB0V1ZkTk5VNHlVbTFPYWtwb1drUm5lRTFDTUVkRGFYTkhRVkZSUWdwbk56aDNRVkZ6UlVSM2QwNWFNbXd3WVVoV2FVeFhhSFpqTTFKc1drUkNaVUpuYjNKQ1owVkZRVmxQTDAxQlJVMUNSa0ZOVkcxb01HUklRbnBQYVRoMkNsb3liREJoU0ZacFRHMU9kbUpUT1hwaFYyUjZaRWM1ZVZwVE1XcGlNalZ0WWpOS2RGbFhOV3BhVXpsc1pVaFNlVnBYTVd4aVNHdDBXa2RHZFZveVZua0tZak5XZWt4WVFqRlpiWGh3V1hreGRtRlhVbXBNVjBwc1dWZE9kbUpxUVRSQ1oyOXlRbWRGUlVGWlR5OU5RVVZPUWtOdlRVdEhUVE5aYWs1cldtMUplZ3BOZWxadFRVUlZlRnBVUm1wUFJGcHBXa2RGTUZsNlkzaE9iVnBvV1hwck0xcEhXVEpOYlVaclQwUkZkMGgzV1V0TGQxbENRa0ZIUkhaNlFVSkVaMUZTQ2tSQk9YbGFWMXA2VERKb2JGbFhVbnBNTWpGb1lWYzBkMGRSV1V0TGQxbENRa0ZIUkhaNlFVSkVkMUZNUkVGck1rMTZTVEZQVkZrMFQxUmpkMDUzV1VzS1MzZFpRa0pCUjBSMmVrRkNSVUZSY0VSRFpHOWtTRkozWTNwdmRrd3laSEJrUjJneFdXazFhbUl5TUhaak1teHVZek5TZG1OdFZYUlpNamwxV20wNWVRcGlWMFoxV1RKVmQwZFJXVXRMZDFsQ1FrRkhSSFo2UVVKRlVWRk1SRUZyZUUxNlJUUk5SRkV4VG1wTmQyZGhXVWREYVhOSFFWRlJRbWMzT0hkQlVrbEZDbWRhWTAxbldsSnZaRWhTZDJONmIzWk1NbVJ3WkVkb01WbHBOV3BpTWpCMll6SnNibU16VW5aamJWVjBXVEk1ZFZwdE9YbGlWMFoxV1RKVmRscFlhREFLWTIxV2RGcFhlRFZNVjFKb1ltMWtiR050T1RGamVURjNaRmRLYzJGWFRYUmlNbXhyV1hreGFWcFhSbXBpTWpSMlRHMWtjR1JIYURGWmFUa3pZak5LY2dwYWJYaDJaRE5OZGxwWWFEQmpiVlowV2xkNE5VeFhVbWhpYldSc1kyMDVNV041TVhaaFYxSnFURmRLYkZsWFRuWmlhVFUxWWxkNFFXTnRWbTFqZVRsdkNscFhSbXRqZVRsMFdWZHNkVTFFWjBkRGFYTkhRVkZSUW1jM09IZEJVazFGUzJkM2IxbDZaR2xOTWxKdFdXcE5lazVYV1hkT1ZFWnNUVmROTkU1dFNtc0tXVlJTYWs1NlJUSmFiVVpxVDFSa2ExcHFXWGxaVjFFMFRWUkJhRUpuYjNKQ1owVkZRVmxQTDAxQlJWVkNRazFOUlZoa2RtTnRkRzFpUnpreldESlNjQXBqTTBKb1pFZE9iMDFKUjBKQ1oyOXlRbWRGUlVGWlR5OU5RVVZXUWtoTlRXTlhhREJrU0VKNlQyazRkbG95YkRCaFNGWnBURzFPZG1KVE9YcGhWMlI2Q21SSE9YbGFVekZxWWpJMWJXSXpTblJaVnpWcVdsTTViR1ZJVW5sYVZ6RnNZa2hyZEZwSFJuVmFNbFo1WWpOV2VreFlRakZaYlhod1dYa3hkbUZYVW1vS1RGZEtiRmxYVG5aaWFUbG9XVE5TY0dJeU5YcE1NMG94WW01TmRrOUVUVEJPZWxFMFRWUlplVTlET1doa1NGSnNZbGhDTUdONU9IaE5RbGxIUTJselJ3cEJVVkZDWnpjNGQwRlNXVVZEUVhkSFkwaFdhV0pIYkdwTlNVZExRbWR2Y2tKblJVVkJaRm8xUVdkUlEwSklkMFZsWjBJMFFVaFpRVE5VTUhkaGMySklDa1ZVU21wSFVqUmpiVmRqTTBGeFNrdFljbXBsVUVzekwyZzBjSGxuUXpod04yODBRVUZCUjA5V09FRkljR2RCUVVKQlRVRlNla0pHUVdsQ1JtVk5ZbkFLUm1GeWJGQjNZakJ1WVZSeU5HMXFWMFIyV0VGd1QyUTVUMUp4VDJzek5rSnlkRGxUYlhkSmFFRktTblpxYjNJclJGaFZXSEkzVXpOV2JUbHFWa1pVTXdwRFREQkNlR05MUjJvNE5tMDFiVmw2VVhaMVlrMUJiMGREUTNGSFUwMDBPVUpCVFVSQk1tZEJUVWRWUTAxQk9HeFVhWGhrVXpScFRqbHRRVlZrZFU5aUNtTlRTbTFvV2t4NWRrczNlbUZZTURWRVRFVkVRMmRRVjNoRVNHc3JTa0phVlV0WlVrbDFTRWhuZDBadVQzZEplRUZNVFdGdGJ6bGtaa1ZPVFhwU1owNEtRM3BaWm5BdmVTdHlVMDlvVm1wWVdFVTViVU51TmtKMVNrVlViSEJTUkdaSGRuaFZaeTgxVEVZNVpqUnNXWEZ2ZWtFOVBRb3RMUzB0TFVWT1JDQkRSVkpVU1VaSlEwRlVSUzB0TFMwdENnPT0ifX19fQ=="}]}, "messageSignature": {"messageDigest": {"algorithm": "SHA2_256", "digest": "oM/HEnHW4njlfNMy/5V8P3BD/do1TEy7GQow1W76Ab8="}, "signature": "MEUCICYFq/4bTEdlurgqVuNmwCcIWu3NKOCgveWAJBiezJ0uAiEA2i7U18+aRpFxLYksr5HKBQQy08zE050WIc0RzKunDIA="}}'''; + +/// Builds a Sigstore bundle carrying a SLSA build provenance statement. +/// +/// The bundle is not signed: it is meant for tests of the parsing and of the +/// verification policy, which operate on material that has already been +/// cryptographically verified. +String buildProvenanceBundleJson({ + String subjectName = 'foo-1.0.0.tar.gz', + String subjectSha256 = + '0000000000000000000000000000000000000000000000000000000000000000', + String repository = 'https://github.com/dieter/helpful', + String ref = 'refs/tags/v1.0.0', + String workflowPath = '.github/workflows/release.yml', + String? commitSha = 'c0ffee7891abbe3dab159e9d0187fc1042d5e0cd', + String? builderId = + 'https://github.com/dart-lang/setup-dart/.github/workflows/' + 'publish.yml@refs/tags/v2', + String statementType = 'https://in-toto.io/Statement/v1', + String predicateType = 'https://slsa.dev/provenance/v1', + String payloadType = 'application/vnd.in-toto+json', +}) { + final statement = { + '_type': statementType, + 'subject': [ + { + 'name': subjectName, + 'digest': {'sha256': subjectSha256}, + }, + ], + 'predicateType': predicateType, + 'predicate': { + 'buildDefinition': { + 'buildType': 'https://actions.github.io/buildtypes/workflow/v1', + 'externalParameters': { + 'workflow': { + 'ref': ref, + 'repository': repository, + 'path': workflowPath, + }, + }, + if (commitSha != null) + 'resolvedDependencies': [ + { + 'uri': 'git+$repository@$ref', + 'digest': {'gitCommit': commitSha}, + }, + ], + }, + 'runDetails': { + if (builderId != null) 'builder': {'id': builderId}, + }, + }, + }; + return json.encode({ + 'mediaType': 'application/vnd.dev.sigstore.bundle.v0.3+json', + 'dsseEnvelope': { + 'payload': base64.encode(utf8.encode(json.encode(statement))), + 'payloadType': payloadType, + 'signatures': [ + {'sig': 'not-a-real-signature'}, + ], + }, + }); +} + +/// The real `helpful-0.1.5.tar.gz` archive built from `mosuem/helpful` by +/// `dart-lang/setup-dart/.github/workflows/publish.yml`. +final Uint8List helpfulArchiveBytes = base64.decode( + "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", +); + +/// The real Sigstore bundle for `helpful 0.1.5`, signed by +/// `dart-lang/setup-dart/.github/workflows/publish.yml`. +const String helpfulBundleJson = + r'''{"mediaType":"application/vnd.dev.sigstore.bundle.v0.3+json","verificationMaterial":{"certificate":{"rawBytes":"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"},"tlogEntries":[{"logIndex":"2676401329","logId":{"keyId":"wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="},"kindVersion":{"kind":"dsse","version":"0.0.1"},"integratedTime":"1788270157","inclusionPromise":{"signedEntryTimestamp":"MEYCIQCF0fJPsDlo0/ETbGoj7P1Tv5t6/KnFTC4li5/hgfp+zQIhAMHiH5jQct+InQUDaLsvrj4ScaLttabrbOtgOOFNP5TB"},"inclusionProof":{"logIndex":"2554497067","rootHash":"3VYInn5w29R1Ik5hPXoKNUXLSpjYDA+eOe15tHZDf3Q=","treeSize":"2554497077","hashes":["vEJRC4WGQtYk/U8bQHZ1GSxv9gXkeDlMle2iNtj5oLk=","A+Z5tXgPG9hRnNyqfrNlhB/lSijIrKbBlghyVizPfDw=","34bE/cxzMRK5jIX0UnN60fixf7k7tlZnLfYP9WwTbAA=","xF6ied/Idor3bSwQm8r7l+4G+22wzhE4Yyo2TSTTJzU=","mI6+Jl7u+5zbsD2btfPoIi4JXtac2ZA3T4OQu5XBQv0=","+kWrmBfcvkeD3p6uiuVZRX0NzFhOAsr66BfgZXcY/gE=","Rt2No6mKQC0/Lm4I88OyNhBZctDQArHU/FWcBQXRKHk=","YnrIcUp47OZh4DKzWnPt6HbUJ47Z6J/60daOiLSJFhM=","PSk3xvEFw94Y6oOvFw7ze0gTgr7OTNkRO9qcIOHxQWc=","/7Mrva+smcuOSAUamgAAnlU6WDjX01mVt0u9CfyOS3w=","eXSLrLqKSxRTixSP/k4YimriB2lufao6trqTrsxxlyg=","SndbMKVtcTenAkwi2JBfGzD+mhexp1qJbRIY+A1JRIU=","xH/DCseLHr9eKoYT8qsORZK7zVdEGYWHuVtsVrD95wY="],"checkpoint":{"envelope":"rekor.sigstore.dev - 1193050959916656506\n2554497077\n3VYInn5w29R1Ik5hPXoKNUXLSpjYDA+eOe15tHZDf3Q=\n\n\u2014 rekor.sigstore.dev wNI9ajBFAiEA+vbOqxP76bar3G0xXiMl0qAQ249eIvk2LPNvYJjep64CIGC3cdY2YUlaa6vc0djTkh1irUcugzw9PpnCKsURbPHp\n"}},"canonicalizedBody":"eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiZHNzZSIsInNwZWMiOnsiZW52ZWxvcGVIYXNoIjp7ImFsZ29yaXRobSI6InNoYTI1NiIsInZhbHVlIjoiZDNkOTMxYjZjN2VjNWNhZjRmOTA0MWQwOWNhZTk4ODY5MjhlODUzMDg1NGY3NzQzMmZkY2QxNWYxM2Q4NjU4YSJ9LCJwYXlsb2FkSGFzaCI6eyJhbGdvcml0aG0iOiJzaGEyNTYiLCJ2YWx1ZSI6IjFhOWQ5MTFmZDBkM2Y2NGY1NmY1ZjZjNGIxMTEwN2VmMzY2MmI0ZGViZGM3YmJjZjY2NTliMzQ4NzY5NzQ4OWQifSwic2lnbmF0dXJlcyI6W3sic2lnbmF0dXJlIjoiTUVZQ0lRRGgzeXY1MXREZ1Q5UWFHemh0VXRBSEFsMWpQa2dqSXF2dlZBcFBmWUNaa3dJaEFNNExlaTZvZDFOc1RrdmZOY21zeVlyT3A0azRhNDljYlhWYVRPK0hoTzAwIiwidmVyaWZpZXIiOiJMUzB0TFMxQ1JVZEpUaUJEUlZKVVNVWkpRMEZVUlMwdExTMHRDazFKU1VoVFZFTkRRbk1yWjBGM1NVSkJaMGxWVTJKTGNuWTFjbkp5VlZVclZtOUtVRE5FYzFaelRVSlZhMkZOZDBObldVbExiMXBKZW1vd1JVRjNUWGNLVG5wRlZrMUNUVWRCTVZWRlEyaE5UV015Ykc1ak0xSjJZMjFWZFZwSFZqSk5ValIzU0VGWlJGWlJVVVJGZUZaNllWZGtlbVJIT1hsYVV6RndZbTVTYkFwamJURnNXa2RzYUdSSFZYZElhR05PVFdwWmQwOVVRWGhOVkUwd1RXcE5NMWRvWTA1TmFsbDNUMVJCZUUxVVRURk5hazB6VjJwQlFVMUdhM2RGZDFsSUNrdHZXa2w2YWpCRFFWRlpTVXR2V2tsNmFqQkVRVkZqUkZGblFVVnpNRmxLVTJSVU1saE9hRll3VUV0Q1lrUndOMVZ5V0hseU9FTTFjR2t4UTNrNFlsUUtNVmMwWkRKNWNEYzJhRzlrUTFZd0szUnZMMVo0WVhjeVJrTnVTa3R4WkVSNlpGWXJRMHR1WW1VeVlrVTVWM0pVYWt0UFEwSmxOSGRuWjFoeFRVRTBSd3BCTVZWa1JIZEZRaTkzVVVWQmQwbElaMFJCVkVKblRsWklVMVZGUkVSQlMwSm5aM0pDWjBWR1FsRmpSRUY2UVdSQ1owNVdTRkUwUlVablVWVnZlakZRQ25JdlZsbFBWVWRCYnpSRWJrbE5lWGRHTlU5UE9VTmpkMGgzV1VSV1VqQnFRa0puZDBadlFWVXpPVkJ3ZWpGWmEwVmFZalZ4VG1wd1MwWlhhWGhwTkZrS1drUTRkMlYzV1VSV1VqQlNRVkZJTDBKSVJYZGlORnAwWVVoU01HTklUVFpNZVRsdVlWaFNiMlJYU1hWWk1qbDBUREpTYUdOdVVYUmlSMFoxV25rNWVncGFXRkl4WTBNeGExbFlTakJNZVRWdVlWaFNiMlJYU1haa01qbDVZVEphYzJJelpIcE1NMEl4V1cxNGNHTXlaM1ZsVnpGelVVaEtiRnB1VFhaaFIxWm9DbHBJVFhaalNGWnBZa2RzZW1GRE1YZFpWMDV5V1Zka2JFeFhSakJrUjFaNlpFZEdNR0ZYT1hWamVrRTFRbWR2Y2tKblJVVkJXVTh2VFVGRlFrSkRkRzhLWkVoU2QyTjZiM1pNTTFKMllUSldkVXh0Um1wa1IyeDJZbTVOZFZveWJEQmhTRlpwWkZoT2JHTnRUblppYmxKc1ltNVJkVmt5T1hSTlFqaEhRMmx6UndwQlVWRkNaemM0ZDBGUlNVVkZXR1IyWTIxMGJXSkhPVE5ZTWxKd1l6TkNhR1JIVG05TlJGbEhRMmx6UjBGUlVVSm5OemgzUVZGTlJVdEVXWHBOYWtVeUNsbFVUWGROYWtwc1drUlpkMDVxVlhwUFJFMDFUMVJaZWs1SFRtdE5WMDB4VFdwV2JVOUhTbXRPUkZreFRqSlpkMHRCV1V0TGQxbENRa0ZIUkhaNlFVSUtRa0ZSWVZWSVZtbGlSMng2WVVOQ2IxcFhlSGRhYmxaelNVaFNka2xJUWpGWmFUVnJXbGhaZDBoQldVdExkMWxDUWtGSFJIWjZRVUpDVVZGUFlsYzVlZ3BrVjFaMFRESm9iR0pJUW0xa1YzZDNTRkZaUzB0M1dVSkNRVWRFZG5wQlFrSm5VVkJqYlZadFkzazViMXBYUm10amVUbDBXVmRzZFUxRWMwZERhWE5IQ2tGUlVVSm5OemgzUVZGblJVeFJkM0poU0ZJd1kwaE5Oa3g1T1RCaU1uUnNZbWsxYUZrelVuQmlNalY2VEcxa2NHUkhhREZaYmxaNldsaEthbUl5TlRBS1dsYzFNRXh0VG5aaVZFSTVRbWR2Y2tKblJVVkJXVTh2VFVGRlNrSkhPRTFpVjJnd1pFaENlazlwT0haYU1td3dZVWhXYVV4dFRuWmlVemxyV1ZoS01BcE1WM2hvWW0xamRtTXlWakJrV0VGMFdrZEdlV1JET0hWYU1td3dZVWhXYVV3elpIWmpiWFJ0WWtjNU0yTjVPWGRrVjBwellWaE9iMHh1YkhSaVJVSjVDbHBYV25wTU1taHNXVmRTZWt3elFqRlpiWGh3WXpKbmRHTkhSbXBoTWtadVdsTXhhR1JJVW14ak0xSm9aRWRzZG1KdVRYZFBRVmxMUzNkWlFrSkJSMFFLZG5wQlFrTm5VWEZFUTJocldtcGtiVmxxWkdoT2JVMTNXV3BuTlUxNlJYZGFWR2Q2VFVSbk1sbHRVVEpOVjBrelRucHJNVTR5VW0xUFZGbDVUMWROTUFwTlFqQkhRMmx6UjBGUlVVSm5OemgzUVZGelJVUjNkMDVhTW13d1lVaFdhVXhYYUhaak0xSnNXa1JCZUVKbmIzSkNaMFZGUVZsUEwwMUJSVTFDUTAxTkNrbFhhREJrU0VKNlQyazRkbG95YkRCaFNGWnBURzFPZG1KVE9YUmlNMDR4V2xjd2RtRkhWbk5qUjFveFlrUkJORUpuYjNKQ1owVkZRVmxQTDAxQlJVNEtRa052VFV0RVdYcE5ha1V5V1ZSTmQwMXFTbXhhUkZsM1RtcFZlazlFVFRWUFZGbDZUa2RPYTAxWFRURk5hbFp0VDBkS2EwNUVXVEZPTWxsM1NIZFpTd3BMZDFsQ1FrRkhSSFo2UVVKRVoxRlNSRUU1ZVZwWFducE1NbWhzV1ZkU2Vrd3lNV2hoVnpSM1IyZFpTMHQzV1VKQ1FVZEVkbnBCUWtSM1VVMUVRVzk0Q2sxNlRUVk5la1Y2VFVSSk1VMURhMGREYVhOSFFWRlJRbWMzT0hkQlVrRkZSM2QzV21GSVVqQmpTRTAyVEhrNWJtRllVbTlrVjBsMVdUSTVkRXd5TVhZS1l6TldiR0pVUVZsQ1oyOXlRbWRGUlVGWlR5OU5RVVZTUWtGdlRVTkVTVE5QUkZVMVRVUm5lVTFIUVVkRGFYTkhRVkZSUW1jM09IZEJVa2xGVldkNFVRcGhTRkl3WTBoTk5reDVPVzVoV0ZKdlpGZEpkVmt5T1hSTU1qRjJZek5XYkdKVE9XOWFWM2gzV201V2MweDVOVzVoV0ZKdlpGZEpkbVF5T1hsaE1scHpDbUl6WkhwTU0wSXhXVzE0Y0dNeVozVmxWMFowWWtWQ2VWcFhXbnBNTW1oc1dWZFNla3d5TVdoaFZ6UjNUMEZaUzB0M1dVSkNRVWRFZG5wQlFrVjNVWEVLUkVObk1rMTZTWGhPYlVWNlRVUkplVnBYVVRKTlJGa3hUWHBuZWs5VWF6Sk5lbEpxV2tSR2FrNVVTVEZhYW1ocFdrUlJNazVVWkcxTlEwVkhRMmx6UndwQlVWRkNaemM0ZDBGU1VVVkZkM2RTWkRJNWVXRXlXbk5pTTJSbVdrZHNlbU5IUmpCWk1tZDNWbEZaUzB0M1dVSkNRVWRFZG5wQlFrWlJVa2hFUlZadkNtUklVbmRqZW05MlRESmtjR1JIYURGWmFUVnFZakl3ZG1KWE9YcGtWMVowVERKb2JHSklRbTFrVjNkMldWZE9NR0ZYT1hWamVUbDVaRmMxZWt4NlRYb0tUbFJGTUU5VVVYcE9WRlV4VERKR01HUkhWblJqU0ZKNlRIcEZkMFpuV1V0TGQxbENRa0ZIUkhaNlFVSkdaMUZKUkVGYWQyUlhTbk5oVjAxM1UzZFpTd3BMZDFsQ1FrRkhSSFo2UVVKSFFWRTVSRVIwZVZwWVFuWlBiVEYyWXpOV2JHSlZRWGxPZW1jeFQxUkJORTFwT1c5YVYzaDNXbTVXYzFGRVJYcE5lbXQ2Q2sxVVRYZE5hbFUyWTIxV2JVOXVTbXhhYmsxMllVZFdhRnBJVFhaaVYwWndZbXBEUW1sM1dVdExkMWxDUWtGSVYyVlJTVVZCWjFJNVFraHpRV1ZSUWpNS1FVNHdPVTFIY2tkNGVFVjVXWGhyWlVoS2JHNU9kMHRwVTJ3Mk5ETnFlWFF2TkdWTFkyOUJka3RsTms5QlFVRkNiMFl3TVVodk5FRkJRVkZFUVVWbmR3cFNaMGxvUVV0c2RISTFaazlNV0djNFZqVnplRU13YzJKS2IwUm1kbWRzVm13MU5sSnZkSEp1T0hWUVYySnRjVzFCYVVWQk5USkRVWFY1WWxCSWRsRmpDbFV3YUN0c05DdGFZbUVyWlVoak9XdFFTV1J0TnpCVk5WVnVkRmhWZW5kM1EyZFpTVXR2V2tsNmFqQkZRWGROUkdGQlFYZGFVVWwzVG5CWmRtdEdlV1FLYTBOeVJ6VTNjVXgwUWk5NVpVOWhSMVp6YzAxUGFsRnBURkY0VTBwNVNHZzViUzkwTDB4VVZXUnhlbWRaVDB0cWVVZENSV2RNYUcxQmFrVkJNbVJwWXdwbVZHaGtWakZUYWtkdVdEZGFPVFZWUzBreFNWQk5VV1pQVHpJek1rSjBjWEkzUVhFeWJGZHNUbWR3TW5oRFJXeHJXV0V2YjBKa1RXdGpSRUlLTFMwdExTMUZUa1FnUTBWU1ZFbEdTVU5CVkVVdExTMHRMUW89In1dfX0="}],"timestampVerificationData":{}},"dsseEnvelope":{"payload":"eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoicGFja2FnZS50YXIuZ3oiLCJkaWdlc3QiOnsic2hhMjU2IjoiYzhlY2ZhNjExMDBlY2UxYTU1ZmNiNTI3MTZkYjc0ZjkyYjZkZWJlYjc1NzdhNGI4Y2E0NTQ4ZjQ0MGJmYTZiZiJ9fV0sInByZWRpY2F0ZVR5cGUiOiJodHRwczovL3Nsc2EuZGV2L3Byb3ZlbmFuY2UvdjEiLCJwcmVkaWNhdGUiOnsiYnVpbGREZWZpbml0aW9uIjp7ImJ1aWxkVHlwZSI6Imh0dHBzOi8vYWN0aW9ucy5naXRodWIuaW8vYnVpbGR0eXBlcy93b3JrZmxvdy92MSIsImV4dGVybmFsUGFyYW1ldGVycyI6eyJ3b3JrZmxvdyI6eyJyZWYiOiJyZWZzL2hlYWRzL21haW4iLCJyZXBvc2l0b3J5IjoiaHR0cHM6Ly9naXRodWIuY29tL21vc3VlbS9oZWxwZnVsIiwicGF0aCI6Ii5naXRodWIvd29ya2Zsb3dzL3B1Ymxpc2gueWFtbCJ9fSwiaW50ZXJuYWxQYXJhbWV0ZXJzIjp7ImdpdGh1YiI6eyJldmVudF9uYW1lIjoid29ya2Zsb3dfZGlzcGF0Y2giLCJyZXBvc2l0b3J5X2lkIjoiMTMzOTMxMzAyNSIsInJlcG9zaXRvcnlfb3duZXJfaWQiOiIyNzg1OTA4MiIsInJ1bm5lcl9lbnZpcm9ubWVudCI6ImdpdGh1Yi1ob3N0ZWQifX0sInJlc29sdmVkRGVwZW5kZW5jaWVzIjpbeyJ1cmkiOiJnaXQraHR0cHM6Ly9naXRodWIuY29tL21vc3VlbS9oZWxwZnVsQHJlZnMvaGVhZHMvbWFpbiIsImRpZ2VzdCI6eyJnaXRDb21taXQiOiI2MzIxNmEzMDIyZWQ2MDY1MzgzOTk2MzRjZDFjNTI1ZjhiZDQ2NTdmIn19XX0sInJ1bkRldGFpbHMiOnsiYnVpbGRlciI6eyJpZCI6Imh0dHBzOi8vZ2l0aHViLmNvbS9kYXJ0LWxhbmcvc2V0dXAtZGFydC8uZ2l0aHViL3dvcmtmbG93cy9wdWJsaXNoLnltbEByZWZzL2hlYWRzL3B1Ymxpc2gtcGFja2FnZS1hdHRlc3RhdGlvbnMifSwibWV0YWRhdGEiOnsiaW52b2NhdGlvbklkIjoiaHR0cHM6Ly9naXRodWIuY29tL21vc3VlbS9oZWxwZnVsL2FjdGlvbnMvcnVucy8zMzUxNDk0MzU1NS9hdHRlbXB0cy8xIn19fX0=","payloadType":"application/vnd.in-toto+json","signatures":[{"sig":"MEYCIQDh3yv51tDgT9QaGzhtUtAHAl1jPkgjIqvvVApPfYCZkwIhAM4Lei6od1NsTkvfNcmsyYrOp4k4a49cbXVaTO+HhO00"}]}}'''; diff --git a/test/sigstore/verifier_test.dart b/test/sigstore/verifier_test.dart index bc28cc586a0..19731453f15 100644 --- a/test/sigstore/verifier_test.dart +++ b/test/sigstore/verifier_test.dart @@ -14,33 +14,91 @@ import 'package:test/test.dart'; import 'test_fixtures.dart'; void main() { - test('successfully verifies valid package and attestation', () { + test('verifies a real signed package attestation (helpful 0.1.5)', () { + final verifier = PubAttestationVerifier(); + + final result = verifier.verify( + packageName: 'helpful', + packageVersion: Version(0, 1, 5), + archiveBytes: helpfulArchiveBytes, + bundleJson: helpfulBundleJson, + declaredPackageName: 'helpful', + declaredPackageVersion: Version(0, 1, 5), + expectedRepository: 'https://github.com/mosuem/helpful', + ); + + expect(result.errors, isEmpty); + expect(result.isValid, isTrue); + expect(result.repository, 'https://github.com/mosuem/helpful'); + expect( + result.provenance?.commitSha, + '63216a3022ed606538399634cd1c525f8bd4657f', + ); + }); + + test('rejects a real attestation when served for another package', () { + final verifier = PubAttestationVerifier(); + + final result = verifier.verify( + packageName: 'evil', + packageVersion: Version(0, 1, 5), + archiveBytes: helpfulArchiveBytes, + bundleJson: helpfulBundleJson, + declaredPackageName: 'helpful', + declaredPackageVersion: Version(0, 1, 5), + expectedRepository: 'https://github.com/mosuem/helpful', + ); + + expect(result.isValid, isFalse); + expect( + result.errors.single, + contains('declares `name: helpful`, expected `evil`'), + ); + }); + + test( + 'rejects a real attestation when pubspec declares another repository', + () { + final verifier = PubAttestationVerifier(); + + final result = verifier.verify( + packageName: 'helpful', + packageVersion: Version(0, 1, 5), + archiveBytes: helpfulArchiveBytes, + bundleJson: helpfulBundleJson, + declaredPackageName: 'helpful', + declaredPackageVersion: Version(0, 1, 5), + expectedRepository: 'https://github.com/evil/helpful', + ); + + expect(result.isValid, isFalse); + expect(result.errors.single, contains('was built from')); + }, + ); + + test('rejects a bundle that carries no build provenance', () { + // This bundle is cryptographically valid, and was produced by a GitHub + // Actions workflow - but it is a plain artifact signature over some other + // file, made by an unrelated repository. Accepting it would mean accepting + // any valid bundle from any workflow as the attestation of any package. final verifier = PubAttestationVerifier(); - final bundle = SigstoreBundle.fromJson(sampleBundleJson); final result = verifier.verify( packageName: 'sample', packageVersion: Version(1, 0, 0), archiveBytes: sampleArtifactBytes, - bundle: bundle, + bundleJson: sampleBundleJson, expectedRepository: - 'https://github.com/sigstore-conformance/extremely-dangerous-public-oidc-beacon', + 'https://github.com/sigstore-conformance/' + 'extremely-dangerous-public-oidc-beacon', ); - expect(result.isValid, isTrue); - expect(result.errors, isEmpty); - expect(result.packageName, equals('sample')); - expect( - result.repository, - equals( - 'https://github.com/sigstore-conformance/extremely-dangerous-public-oidc-beacon', - ), - ); + expect(result.isValid, isFalse); + expect(result.errors.single, contains('carries no build provenance')); }); test('fails when archive bytes do not match attestation', () { final verifier = PubAttestationVerifier(); - final bundle = SigstoreBundle.fromJson(sampleBundleJson); final tamperedBytes = Uint8List.fromList([1, 2, 3, 4, 5]); @@ -48,45 +106,42 @@ void main() { packageName: 'sample', packageVersion: Version(1, 0, 0), archiveBytes: tamperedBytes, - bundle: bundle, + bundleJson: sampleBundleJson, ); expect(result.isValid, isFalse); expect(result.errors, isNotEmpty); }); - test('fails when repository does not match expected repository', () { + test('fails when the attestation is not a Sigstore bundle', () { final verifier = PubAttestationVerifier(); - final bundle = SigstoreBundle.fromJson(sampleBundleJson); final result = verifier.verify( packageName: 'sample', packageVersion: Version(1, 0, 0), archiveBytes: sampleArtifactBytes, - bundle: bundle, - expectedRepository: 'https://github.com/unexpected-owner/unexpected-repo', + bundleJson: '{"not": "a bundle"}', ); expect(result.isValid, isFalse); - expect(result.errors.first, contains('does not match expected repository')); + expect(result.errors, isNotEmpty); }); - test('fails when expected repository is not on GitHub', () { + test('fails on an unsigned provenance statement', () { + // `buildProvenanceBundleJson` produces a well-formed statement with a + // bogus signature: the policy must never be reached. final verifier = PubAttestationVerifier(); - final bundle = SigstoreBundle.fromJson(sampleBundleJson); final result = verifier.verify( - packageName: 'sample', + packageName: 'helpful', packageVersion: Version(1, 0, 0), archiveBytes: sampleArtifactBytes, - bundle: bundle, - expectedRepository: 'https://gitlab.com/unexpected-owner/unexpected-repo', + bundleJson: buildProvenanceBundleJson( + subjectName: 'helpful-1.0.0.tar.gz', + ), + expectedRepository: 'https://github.com/dieter/helpful', ); expect(result.isValid, isFalse); - expect( - result.errors.first, - contains('currently only supported for GitHub repositories'), - ); }); } From a96a9c3f38b4edded781db550bc51b1c3228475b Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 12 Oct 2026 09:06:16 +0000 Subject: [PATCH 09/12] Fix lints in attestation tests (line length, unused import, redundant argument, quotes) --- .../hosted/attestation_verification_test.dart | 118 +++++++++--------- test/sigstore/policy_test.dart | 1 - test/sigstore/provenance_test.dart | 1 - test/sigstore/test_fixtures.dart | 2 +- 4 files changed, 59 insertions(+), 63 deletions(-) diff --git a/test/get/hosted/attestation_verification_test.dart b/test/get/hosted/attestation_verification_test.dart index 7626510a7a2..a76b30265e8 100644 --- a/test/get/hosted/attestation_verification_test.dart +++ b/test/get/hosted/attestation_verification_test.dart @@ -17,71 +17,69 @@ import '../../sigstore/test_fixtures.dart'; import '../../test_pub.dart'; void main() { - test( - 'verifies a real signed package attestation and records provenance (helpful 0.1.5)', - () async { - final server = await servePackages(); - server.serve( - 'helpful', - '0.1.5', - slsaLevel: 2, - archiveBytes: helpfulArchiveBytes, - pubspec: {'repository': 'https://github.com/mosuem/helpful'}, + test('verifies a real signed package attestation and records provenance ' + '(helpful 0.1.5)', () async { + final server = await servePackages(); + server.serve( + 'helpful', + '0.1.5', + slsaLevel: 2, + archiveBytes: helpfulArchiveBytes, + pubspec: {'repository': 'https://github.com/mosuem/helpful'}, + ); + server.handle('/api/packages/helpful/versions/0.1.5/attestation', ( + request, + ) { + return Response.ok( + helpfulBundleJson, + headers: {'content-type': 'application/json; charset="utf-8"'}, ); - server.handle('/api/packages/helpful/versions/0.1.5/attestation', ( - request, - ) { - return Response.ok( - helpfulBundleJson, - headers: {'content-type': 'application/json; charset="utf-8"'}, - ); - }); - - await d - .appDir( - dependencies: { - 'helpful': {'hosted': server.url, 'version': '^0.1.0'}, - }, - ) - .create(); - - await pubGet(); + }); - final cacheDir = server.pathInCache('helpful', '0.1.5'); - expect(Directory(cacheDir).existsSync(), isTrue); + await d + .appDir( + dependencies: { + 'helpful': {'hosted': server.url, 'version': '^0.1.0'}, + }, + ) + .create(); - final versionProvenance = File( - p.join(server.provenanceCachingPath, 'helpful-0.1.5.provenance'), - ); - final packageProvenance = File( - p.join(server.provenanceCachingPath, 'helpful.provenance'), - ); - expect( - versionProvenance.readAsStringSync(), - 'https://github.com/mosuem/helpful', - ); - expect( - packageProvenance.readAsStringSync(), - 'https://github.com/mosuem/helpful', - ); + await pubGet(); - final lockfile = - File(p.join(d.sandbox, appPath, 'pubspec.lock')).readAsStringSync(); - expect( - lockfile, - contains('provenance: "https://github.com/mosuem/helpful"'), - ); + final cacheDir = server.pathInCache('helpful', '0.1.5'); + expect(Directory(cacheDir).existsSync(), isTrue); - // Serving a newer unattested version of the same package must be - // rejected as a downgrade even if the lockfile is deleted. - File(p.join(d.sandbox, appPath, 'pubspec.lock')).deleteSync(); - server.serve('helpful', '0.1.6'); - await pubGet( - error: contains('Downgrading from a signed package'), - exitCode: DATA, - ); - }, - ); + final versionProvenance = File( + p.join(server.provenanceCachingPath, 'helpful-0.1.5.provenance'), + ); + final packageProvenance = File( + p.join(server.provenanceCachingPath, 'helpful.provenance'), + ); + expect( + versionProvenance.readAsStringSync(), + 'https://github.com/mosuem/helpful', + ); + expect( + packageProvenance.readAsStringSync(), + 'https://github.com/mosuem/helpful', + ); + + final lockfile = + File(p.join(d.sandbox, appPath, 'pubspec.lock')).readAsStringSync(); + expect( + lockfile, + contains('provenance: "https://github.com/mosuem/helpful"'), + ); + + // Serving a newer unattested version of the same package must be + // rejected as a downgrade even if the lockfile is deleted. + File(p.join(d.sandbox, appPath, 'pubspec.lock')).deleteSync(); + server.serve('helpful', '0.1.6'); + await pubGet( + error: contains('Downgrading from a signed package'), + exitCode: DATA, + ); + }); test('proceeds when no attestation is served by repository', () async { final server = await servePackages(); diff --git a/test/sigstore/policy_test.dart b/test/sigstore/policy_test.dart index addebecfd84..6141cea7992 100644 --- a/test/sigstore/policy_test.dart +++ b/test/sigstore/policy_test.dart @@ -5,7 +5,6 @@ @TestOn('vm') library; -import 'package:pub/src/sigstore/provenance.dart'; import 'package:pub/src/sigstore/verifier.dart'; import 'package:pub_semver/pub_semver.dart'; import 'package:test/test.dart'; diff --git a/test/sigstore/provenance_test.dart b/test/sigstore/provenance_test.dart index 6cf42286c58..3d90ffa21f6 100644 --- a/test/sigstore/provenance_test.dart +++ b/test/sigstore/provenance_test.dart @@ -117,7 +117,6 @@ void main() { buildProvenanceBundleJson( subjectName: 'helpful-0.1.2.tar.gz', subjectSha256: 'abc123', - repository: 'https://github.com/dieter/helpful', ref: 'refs/tags/v0.1.2', commitSha: 'deadbeef', ), diff --git a/test/sigstore/test_fixtures.dart b/test/sigstore/test_fixtures.dart index c60bedb98a2..f14e6be1b5e 100644 --- a/test/sigstore/test_fixtures.dart +++ b/test/sigstore/test_fixtures.dart @@ -84,7 +84,7 @@ String buildProvenanceBundleJson({ /// The real `helpful-0.1.5.tar.gz` archive built from `mosuem/helpful` by /// `dart-lang/setup-dart/.github/workflows/publish.yml`. final Uint8List helpfulArchiveBytes = base64.decode( - "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', ); /// The real Sigstore bundle for `helpful 0.1.5`, signed by From 7cfab6145a76bcfc214ef4c97eb422f38e7c0d29 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 12 Oct 2026 12:18:02 +0000 Subject: [PATCH 10/12] Address review: verify by digest, fetch attestation separately, trusted root handling - `PubAttestationVerifier.verify` takes the archive's sha256 digest (the content hash computed while the archive was downloaded) and passes it to `package:sigstore` with `isDigest: true`, instead of reading the archive back and hashing it twice more. - The attestation is fetched in its own request with its own retries, after the archive download: a failing attestation endpoint no longer re-downloads the archive on every attempt, and is reported as such (naming the endpoint, and pointing at proxies that do not forward it) instead of as a failure to find the package. A `401`/`403` from the endpoint is handled the same way. - The trusted root is loaded outside of the verification `try`: a missing `PUB_SIGSTORE_TRUST_ROOT` file fails as a local configuration error instead of being attributed to the attestation; a corrupt cached `trusted_root.json` is ignored with a warning and the built-in root is used. - `requireTaggedSignerRef` stays `false` while the publishing workflow lives on a branch; the default and the `job_workflow_sha` TODO now reference the tracking issue dart-lang/pub#4942. - repository-spec-v2.md: spell out that the attestation must be retrievable before the listing advertises it, and that proxies must forward the endpoint; drop the `Accept` header the client does not send. --- doc/repository-spec-v2.md | 15 ++- lib/src/sigstore/trusted_root.dart | 17 +++- lib/src/sigstore/verifier.dart | 51 ++++++---- lib/src/source/hosted.dart | 96 +++++++++++++------ .../hosted/attestation_verification_test.dart | 33 +++++++ test/sigstore/trusted_root_test.dart | 12 +++ test/sigstore/verifier_test.dart | 15 +-- 7 files changed, 180 insertions(+), 59 deletions(-) diff --git a/doc/repository-spec-v2.md b/doc/repository-spec-v2.md index 6f29aaacff1..ad960395b07 100644 --- a/doc/repository-spec-v2.md +++ b/doc/repository-spec-v2.md @@ -295,9 +295,6 @@ attestation, and the client will fetch and verify it, see **GET** `/api/packages//versions//attestation` -**Headers** -* `Accept: application/json` - **Response** * `Content-Type: application/json` @@ -310,6 +307,18 @@ will fail the download if the attestation cannot be retrieved, rather than install it unverified. Repositories that do not support attestations should respond `404` here and omit `slsa_level`. +Two consequences of this for repository implementers: + +* The attestation must be retrievable *before* a version listing that + advertises it (with `slsa_level`) is served. A client that gets a `404` here + does not retry: it fails the download. Repositories that publish the version + listing through a separate system (e.g. an exported bucket) must order the + two accordingly. +* A proxy or mirror that passes the version listing through verbatim, but does + not forward requests to this end-point, breaks every attested package for its + users. Such a proxy must either forward this end-point or strip `slsa_level` + from the listings it serves. + The attestation is not a substitute for the repository being trustworthy about *which* versions are attested; it is what allows the client to check the archive without trusting the repository about its *contents*. diff --git a/lib/src/sigstore/trusted_root.dart b/lib/src/sigstore/trusted_root.dart index 76866f7d0f1..7f8ef73b773 100644 --- a/lib/src/sigstore/trusted_root.dart +++ b/lib/src/sigstore/trusted_root.dart @@ -6,6 +6,7 @@ import 'dart:convert'; import '../exceptions.dart'; import '../io.dart'; +import '../log.dart' as log; import '../platform_info.dart'; import '../system_cache.dart'; @@ -40,7 +41,21 @@ String? loadTrustedRootJson({SystemCache? cache, String? overridePath}) { final cachePath = cache?.sigstoreTrustedRootPath; if (cachePath != null && fileExists(cachePath)) { - return readTextFile(cachePath); + final text = readTextFile(cachePath); + // The cached root is not something the user wrote: if it is corrupt, fall + // back to the built-in root rather than failing every attested package. + try { + if (jsonDecode(text) is! Map) { + throw const FormatException('not a JSON object'); + } + } on FormatException catch (e) { + log.warning( + 'Ignoring the corrupt Sigstore trusted root at "$cachePath" ' + '($e), using the built-in one.', + ); + return null; + } + return text; } return null; diff --git a/lib/src/sigstore/verifier.dart b/lib/src/sigstore/verifier.dart index e8c472ca7e5..faca7ccc946 100644 --- a/lib/src/sigstore/verifier.dart +++ b/lib/src/sigstore/verifier.dart @@ -83,6 +83,9 @@ class PubAttestationVerifier { String? overrideTrustedRootJson, bool offline = true, Set trustedSignerWorkflows = defaultTrustedSignerWorkflows, + // TODO(https://github.com/dart-lang/pub/issues/4942): Default to `true` + // once the publishing workflow of `dart-lang/setup-dart` is released on a + // tag. Until then any branch of that repository is accepted. bool requireTaggedSignerRef = false, }) : _cache = cache, _overrideTrustedRootPath = overrideTrustedRootPath, @@ -91,7 +94,8 @@ class PubAttestationVerifier { _trustedSignerWorkflows = trustedSignerWorkflows, _requireTaggedSignerRef = requireTaggedSignerRef; - /// Verifies that [archiveBytes] is the archive of [packageName] at + /// Verifies that the archive with the sha256 digest [archiveSha256] is the + /// archive of [packageName] at /// [packageVersion], built by a trusted workflow from [expectedRepository]. /// /// [bundleJson] is the Sigstore bundle as served by the package repository. @@ -100,7 +104,7 @@ class PubAttestationVerifier { /// guaranteed to happen on the same bytes. /// /// [expectedRepository] is the `repository:` field of the `pubspec.yaml` - /// *inside* [archiveBytes]. The pubspec of the version listing must not be + /// *inside* the archive. The pubspec of the version listing must not be /// used: only the archive is covered by the attested digest. If /// [requireRepository] is `true`, verification fails when /// [expectedRepository] is missing - an attestation that is not bound to a @@ -109,26 +113,36 @@ class PubAttestationVerifier { AttestationVerificationResult verify({ required String packageName, required Version packageVersion, - required List archiveBytes, + required List archiveSha256, required String bundleJson, String? declaredPackageName, Version? declaredPackageVersion, String? expectedRepository, bool requireRepository = true, }) { + if (archiveSha256.length != 32) { + throw ArgumentError.value( + archiveSha256, + 'archiveSha256', + 'must be a 32-byte sha256 digest', + ); + } + + // Loaded outside of the `try` below: a missing or unreadable trusted root + // is a local configuration problem, not a problem with the attestation, + // and must not be reported as one. An empty string makes + // `package:sigstore` fall back to its built-in production root of trust. + final trustedRootJson = + _overrideTrustedRootJson ?? + loadTrustedRootJson( + cache: _cache, + overridePath: _overrideTrustedRootPath, + ) ?? + ''; + String? identity; String? issuer; try { - // A `null` trusted root makes `package:sigstore` fall back to its - // built-in production root of trust. - final trustedRootJson = - _overrideTrustedRootJson ?? - loadTrustedRootJson( - cache: _cache, - overridePath: _overrideTrustedRootPath, - ) ?? - ''; - // Throws [SigstoreError.invalidBundle], handled below. final bundle = SigstoreBundle.fromJson(bundleJson); @@ -147,7 +161,10 @@ class PubAttestationVerifier { '', ); - final result = client.verify(archiveBytes, false, bundle, policy); + // Verifying against the digest (`isDigest: true`): the archive was + // already hashed while it was downloaded, and the subject digest of the + // in-toto statement is compared against it. + final result = client.verify(archiveSha256, true, bundle, policy); if (!result.isValid()) { return AttestationVerificationResult.failure( packageName: packageName, @@ -189,7 +206,7 @@ class PubAttestationVerifier { final errors = checkProvenancePolicy( packageName: packageName, packageVersion: packageVersion, - archiveSha256: sha256.convert(archiveBytes).toString(), + archiveSha256: Digest(archiveSha256).toString(), provenance: provenance, signer: signer, oidcIssuer: issuer, @@ -314,8 +331,8 @@ List checkProvenancePolicy({ '${(trustedSignerWorkflows.toList()..sort()).join(', ')}.', ); } else if (requireTaggedSignerRef && !signer.ref.startsWith('refs/tags/')) { - // TODO(sigurdm): Pin the signing workflow by the `job_workflow_sha` - // certificate extension (OID 1.3.6.1.4.1.57264.1.20) once + // TODO(https://github.com/dart-lang/pub/issues/4942): Pin the signing + // workflow by the `job_workflow_sha` certificate extension once // `package:sigstore` exposes it. A ref is mutable, a sha is not. errors.add( 'Attestation was signed by "${signer.workflow}" at "${signer.ref}", ' diff --git a/lib/src/source/hosted.dart b/lib/src/source/hosted.dart index 02cc2c01e85..721d62da26a 100644 --- a/lib/src/source/hosted.dart +++ b/lib/src/source/hosted.dart @@ -1812,37 +1812,19 @@ See $contentHashesDocumentationUrl. // and cancelling a http stream makes it not reusable. There are // ways around this, and we might revisit this later. await createFileFromStream(stream, archivePath); - - // Fetch the Sigstore attestation. It is verified below, once the - // archive has been extracted and we can read the `repository` - // field of the pubspec it contains. - if (expectAttestation) { - final attestationUri = Uri.parse(description.url).resolve( - 'api/packages/${id.name}/versions/${id.version}/attestation', - ); - try { - final attRequest = http.Request('GET', attestationUri); - attestationJson = (await client.fetch(attRequest)).body; - } on PubHttpResponseException catch (e) { - if (e.response.statusCode != HttpStatus.notFound) { - // Do not swallow errors here: an unverified package must not - // be the result of a failed request. Intermittent failures - // are retried by [retryForHttp]. - rethrow; - } - log.fine( - 'No attestation served for ${id.name}-${id.version} ' - 'at $attestationUri.', - ); - attestationJson = null; - } - } }); }); } on Exception catch (error, stackTrace) { _throwFriendlyError(error, stackTrace, id.name, description.url); } + // Fetch the Sigstore attestation. It is verified below, once the archive + // has been extracted and we can read the `repository` field of the + // pubspec it contains. + if (expectAttestation) { + attestationJson = await _fetchAttestation(id, description, cache); + } + try { await extractTarGz(readBinaryFileAsStream(archivePath), destPath); } on FormatException catch (e) { @@ -1854,7 +1836,7 @@ See $contentHashesDocumentationUrl. description, cache, attestationJson: attestationJson, - archivePath: archivePath, + archiveSha256: contentHash!, extractedPath: destPath, slsaLevel: versionInfo.slsaLevel, previousProvenance: previousProvenance, @@ -1866,9 +1848,61 @@ See $contentHashesDocumentationUrl. }); } - /// Verifies [attestationJson] against the archive at [archivePath], which has - /// been extracted to [extractedPath], and returns the repository the package - /// was built from. + /// Fetches the Sigstore attestation of [id] from the package repository, or + /// returns `null` if the repository serves none (`404`). + /// + /// This is a separate request from the archive download with its own + /// retries: a failing attestation endpoint must neither re-download the + /// archive on every attempt nor be reported as a failure to find the + /// package. + Future _fetchAttestation( + PackageId id, + HostedDescription description, + SystemCache cache, + ) async { + final attestationUri = Uri.parse( + description.url, + ).resolve('api/packages/${id.name}/versions/${id.version}/attestation'); + try { + return await withAuthenticatedClient(cache, Uri.parse(description.url), ( + client, + ) async { + return await retryForHttp('fetching "$attestationUri"', () async { + try { + final request = http.Request('GET', attestationUri); + return (await client.fetch(request)).body; + } on PubHttpResponseException catch (e) { + if (e.response.statusCode == HttpStatus.notFound) { + log.fine( + 'No attestation served for ${id.name}-${id.version} ' + 'at $attestationUri.', + ); + return null; + } + // Do not swallow errors here: an unverified package must not be + // the result of a failed request. Intermittent failures are + // retried by [retryForHttp]. + rethrow; + } + }); + }); + } on Exception catch (e) { + // `PubHttpException` after the retries are exhausted, or an + // `AuthenticationException` for a `401`/`403` from the endpoint. + if (e is! PubHttpException && e is! AuthenticationException) rethrow; + throw PackageIntegrityException(''' +Failed to fetch the attestation of ${id.name}-${id.version} from `$attestationUri`: +${e is PubHttpException ? e.message : e} + +This indicates a problem on the package repository `${description.url}`, or +with a proxy in front of it that does not forward attestation requests. +'''); + } + } + + /// Verifies [attestationJson] against the archive with the sha256 digest + /// [archiveSha256], which has been extracted to [extractedPath], and returns + /// the repository the package was built from. /// /// Returns `null` if the package is not attested, and throws if it should /// have been. @@ -1877,7 +1911,7 @@ See $contentHashesDocumentationUrl. HostedDescription description, SystemCache cache, { required String? attestationJson, - required String archivePath, + required Uint8List archiveSha256, required String extractedPath, required int? slsaLevel, required String? previousProvenance, @@ -1907,7 +1941,7 @@ This indicates a problem on the package repository: `${description.url}`. result = PubAttestationVerifier(cache: cache).verify( packageName: id.name, packageVersion: id.version, - archiveBytes: readBinaryFile(archivePath), + archiveSha256: archiveSha256, bundleJson: attestationJson, declaredPackageName: pubspec.name, declaredPackageVersion: pubspec.version, diff --git a/test/get/hosted/attestation_verification_test.dart b/test/get/hosted/attestation_verification_test.dart index a76b30265e8..83891d1510c 100644 --- a/test/get/hosted/attestation_verification_test.dart +++ b/test/get/hosted/attestation_verification_test.dart @@ -126,6 +126,39 @@ void main() { }, ); + test('pub get fails with a clear message when the attestation endpoint ' + 'fails', () async { + // A failing attestation endpoint (or a proxy that does not forward it) + // must be reported as such, not as a failure to find the package, and + // must not re-download the archive on every attempt. + final server = await servePackages(); + server.serve('foo', '1.0.0', slsaLevel: 2); + server.handle('/api/packages/foo/versions/1.0.0/attestation', (request) { + return Response.forbidden('nope'); + }); + + await d + .appDir( + dependencies: { + 'foo': {'hosted': server.url, 'version': '^1.0.0'}, + }, + ) + .create(); + + await pubGet( + error: allOf( + contains('Failed to fetch the attestation of foo-1.0.0 from'), + contains('/api/packages/foo/versions/1.0.0/attestation'), + contains('proxy'), + ), + exitCode: TEMP_FAIL, + ); + expect( + server.requestedPaths.where((path) => path.endsWith('1.0.0.tar.gz')), + hasLength(1), + ); + }); + test('pub get fails when an attestation is claimed but not served', () async { // Reporting `slsa_level` and then not serving an attestation must not // silently downgrade to an unverified install. diff --git a/test/sigstore/trusted_root_test.dart b/test/sigstore/trusted_root_test.dart index a62e7dc7dd6..26a3719ba68 100644 --- a/test/sigstore/trusted_root_test.dart +++ b/test/sigstore/trusted_root_test.dart @@ -65,6 +65,18 @@ void main() { expect(root['certificateAuthorities'], isEmpty); }); + test('ignores a corrupt trusted_root.json in the pub cache', () async { + // The cached root is not something the user wrote: a corrupt one must not + // fail every attested package, the built-in root is used instead. + await d.dir('cache', [ + d.dir('sigstore', [d.file('trusted_root.json', '{not json')]), + ]).create(); + + final cache = SystemCache(rootDir: p.join(d.sandbox, 'cache')); + expect(loadTrustedRootJson(cache: cache), isNull); + expect(loadTrustedRoot(cache: cache), isNull); + }); + test('returns null when no override or cache is found', () { final root = loadTrustedRoot(); expect(root, isNull); diff --git a/test/sigstore/verifier_test.dart b/test/sigstore/verifier_test.dart index 19731453f15..21cc36daf5b 100644 --- a/test/sigstore/verifier_test.dart +++ b/test/sigstore/verifier_test.dart @@ -7,6 +7,7 @@ library; import 'dart:typed_data'; +import 'package:crypto/crypto.dart'; import 'package:pub/src/sigstore/verifier.dart'; import 'package:pub_semver/pub_semver.dart'; import 'package:test/test.dart'; @@ -20,7 +21,7 @@ void main() { final result = verifier.verify( packageName: 'helpful', packageVersion: Version(0, 1, 5), - archiveBytes: helpfulArchiveBytes, + archiveSha256: sha256.convert(helpfulArchiveBytes).bytes, bundleJson: helpfulBundleJson, declaredPackageName: 'helpful', declaredPackageVersion: Version(0, 1, 5), @@ -42,7 +43,7 @@ void main() { final result = verifier.verify( packageName: 'evil', packageVersion: Version(0, 1, 5), - archiveBytes: helpfulArchiveBytes, + archiveSha256: sha256.convert(helpfulArchiveBytes).bytes, bundleJson: helpfulBundleJson, declaredPackageName: 'helpful', declaredPackageVersion: Version(0, 1, 5), @@ -64,7 +65,7 @@ void main() { final result = verifier.verify( packageName: 'helpful', packageVersion: Version(0, 1, 5), - archiveBytes: helpfulArchiveBytes, + archiveSha256: sha256.convert(helpfulArchiveBytes).bytes, bundleJson: helpfulBundleJson, declaredPackageName: 'helpful', declaredPackageVersion: Version(0, 1, 5), @@ -86,7 +87,7 @@ void main() { final result = verifier.verify( packageName: 'sample', packageVersion: Version(1, 0, 0), - archiveBytes: sampleArtifactBytes, + archiveSha256: sha256.convert(sampleArtifactBytes).bytes, bundleJson: sampleBundleJson, expectedRepository: 'https://github.com/sigstore-conformance/' @@ -105,7 +106,7 @@ void main() { final result = verifier.verify( packageName: 'sample', packageVersion: Version(1, 0, 0), - archiveBytes: tamperedBytes, + archiveSha256: sha256.convert(tamperedBytes).bytes, bundleJson: sampleBundleJson, ); @@ -119,7 +120,7 @@ void main() { final result = verifier.verify( packageName: 'sample', packageVersion: Version(1, 0, 0), - archiveBytes: sampleArtifactBytes, + archiveSha256: sha256.convert(sampleArtifactBytes).bytes, bundleJson: '{"not": "a bundle"}', ); @@ -135,7 +136,7 @@ void main() { final result = verifier.verify( packageName: 'helpful', packageVersion: Version(1, 0, 0), - archiveBytes: sampleArtifactBytes, + archiveSha256: sha256.convert(sampleArtifactBytes).bytes, bundleJson: buildProvenanceBundleJson( subjectName: 'helpful-1.0.0.tar.gz', ), From e0017d28cbf7e1791882635d77951337cf11d8a7 Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 23 Oct 2026 09:01:20 +0000 Subject: [PATCH 11/12] Use the provenance parsing and policy from package:sigstore/provenance.dart Replaces lib/src/sigstore/provenance.dart and checkProvenancePolicy with the copies that moved to package:sigstore (dart-lang/tools#2710), along with their tests. The policy takes the version as a string there, and the trusted signer workflows are passed explicitly; defaultTrustedSignerWorkflows stays here. Until package:sigstore 0.2.5 is published from dart-lang/tools, this is a git dependency pinned to the commit of dart-lang/tools#2710. --- lib/src/sigstore/lockfile_policy.dart | 2 +- lib/src/sigstore/provenance.dart | 344 -------------------------- lib/src/sigstore/verifier.dart | 142 +---------- pubspec.lock | 65 ++++- pubspec.yaml | 8 +- test/sigstore/policy_test.dart | 173 ------------- test/sigstore/provenance_test.dart | 195 --------------- 7 files changed, 70 insertions(+), 859 deletions(-) delete mode 100644 lib/src/sigstore/provenance.dart delete mode 100644 test/sigstore/policy_test.dart delete mode 100644 test/sigstore/provenance_test.dart diff --git a/lib/src/sigstore/lockfile_policy.dart b/lib/src/sigstore/lockfile_policy.dart index db67adc93b9..a89b1fb077a 100644 --- a/lib/src/sigstore/lockfile_policy.dart +++ b/lib/src/sigstore/lockfile_policy.dart @@ -3,9 +3,9 @@ // BSD-style license that can be found in the LICENSE file. import 'package:pub_semver/pub_semver.dart'; +import 'package:sigstore/provenance.dart'; import '../exceptions.dart'; -import 'provenance.dart'; /// Exception thrown when package integrity or provenance policies fail. class PackageProvenanceException extends DataException { diff --git a/lib/src/sigstore/provenance.dart b/lib/src/sigstore/provenance.dart deleted file mode 100644 index 6e611388705..00000000000 --- a/lib/src/sigstore/provenance.dart +++ /dev/null @@ -1,344 +0,0 @@ -// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file -// for details. All rights reserved. Use of this source code is governed by a -// BSD-style license that can be found in the LICENSE file. - -/// Parsing of the SLSA build provenance carried in a Sigstore bundle. -/// -/// `package:sigstore` verifies the cryptographic material of a bundle, and -/// exposes the signer identity from the Fulcio certificate. It does not expose -/// the signed payload, so we parse the DSSE envelope of the bundle ourselves. -/// -/// This is safe as long as the exact same bundle JSON is handed to -/// `package:sigstore` for signature verification and to -/// [BuildProvenance.parseBundle] - the payload we inspect here is then -/// guaranteed to be the payload the signature was verified over. To make it -/// hard to get this wrong, `PubAttestationVerifier.verify` takes the bundle as -/// JSON and does both. -library; - -import 'dart:convert'; - -/// The OIDC issuer of tokens minted by GitHub Actions. -const githubActionsOidcIssuer = 'https://token.actions.githubusercontent.com'; - -const _inTotoStatementType = 'https://in-toto.io/Statement/v1'; -const _inTotoPayloadType = 'application/vnd.in-toto+json'; -const _slsaProvenancePredicateType = 'https://slsa.dev/provenance/v1'; - -/// A GitHub repository, identified by its [owner] and [name]. -/// -/// GitHub treats owner and repository names case-insensitively, and so does -/// [operator ==]. -class GitHubRepository { - final String owner; - final String name; - - GitHubRepository(this.owner, this.name); - - /// Parses a reference to a GitHub repository, or returns `null` if [source] - /// does not denote one. - /// - /// Accepts the forms used in `pubspec.yaml`'s `repository:` field and in - /// SLSA provenance statements: - /// - /// * `https://github.com//` - /// * `https://github.com//.git` - /// * `git+https://github.com//@` - /// * `https://github.com///tree//` (monorepos) - /// - /// Everything after `` is ignored: a package living in a subdirectory - /// of a monorepo is published from the repository as a whole, so the - /// subdirectory carries no information for verification. - static GitHubRepository? tryParse(String source) { - var s = source.trim(); - if (s.isEmpty) return null; - // Provenance uses SPDX-style download locations such as - // `git+https://github.com/dart-lang/pub@refs/tags/v1.0.0`. - if (s.startsWith('git+')) { - s = s.substring('git+'.length); - final at = s.lastIndexOf('@'); - if (at > 0) s = s.substring(0, at); - } - final uri = Uri.tryParse(s); - if (uri == null) return null; - if (uri.scheme != 'http' && uri.scheme != 'https') return null; - // Compare the full host: `endsWith('github.com')` would also accept - // `github.com.example.com`. - final host = uri.host.toLowerCase(); - if (host != 'github.com' && host != 'www.github.com') return null; - final segments = uri.pathSegments.where((s) => s.isNotEmpty).toList(); - if (segments.length < 2) return null; - final owner = segments[0]; - var name = segments[1]; - if (name.endsWith('.git')) { - name = name.substring(0, name.length - '.git'.length); - } - if (owner.isEmpty || name.isEmpty) return null; - return GitHubRepository(owner, name); - } - - /// `/`, as used by the GitHub API and in OIDC claims. - String get slug => '$owner/$name'; - - /// The canonical url of this repository. - String get url => 'https://github.com/$owner/$name'; - - @override - bool operator ==(Object other) => - other is GitHubRepository && - owner.toLowerCase() == other.owner.toLowerCase() && - name.toLowerCase() == other.name.toLowerCase(); - - @override - int get hashCode => Object.hash(owner.toLowerCase(), name.toLowerCase()); - - @override - String toString() => url; -} - -/// The identity of the workflow that signed an attestation. -/// -/// This is the `job_workflow_ref` claim of the GitHub Actions OIDC token, -/// which Fulcio puts in the subject alternative name of the signing -/// certificate. For a reusable workflow it identifies the *reusable* workflow -/// - the trusted builder - and not the repository that called it. -class GitHubWorkflowIdentity { - final GitHubRepository repository; - - /// The path of the workflow file within [repository], e.g. - /// `.github/workflows/publish.yml`. - final String path; - - /// The git ref the workflow ran at, e.g. `refs/tags/v2`. - final String ref; - - GitHubWorkflowIdentity({ - required this.repository, - required this.path, - required this.ref, - }); - - /// Parses an identity of the form - /// `https://github.com///@`. - static GitHubWorkflowIdentity? tryParse(String identity) { - final at = identity.lastIndexOf('@'); - if (at <= 0) return null; - final url = identity.substring(0, at); - final ref = identity.substring(at + 1); - if (ref.isEmpty) return null; - final repository = GitHubRepository.tryParse(url); - if (repository == null) return null; - final uri = Uri.tryParse(url); - if (uri == null) return null; - final segments = uri.pathSegments.where((s) => s.isNotEmpty).toList(); - if (segments.length < 3) return null; - return GitHubWorkflowIdentity( - repository: repository, - path: segments.sublist(2).join('/'), - ref: ref, - ); - } - - /// `//` - the workflow without the ref it ran at. - String get workflow => '${repository.slug}/$path'; - - @override - String toString() => '${repository.url}/$path@$ref'; -} - -/// An entry of the `subject` list of an in-toto statement. -class ProvenanceSubject { - final String name; - - /// The lowercase hex-encoded sha256 digest, or `null` if the subject is not - /// identified by a sha256. - final String? sha256; - - ProvenanceSubject(this.name, this.sha256); -} - -/// The SLSA build provenance of an artifact, as carried in the DSSE envelope -/// of a Sigstore bundle. -class BuildProvenance { - /// The repository the build ran for. - /// - /// This is `github.repository` of the workflow run - for a reusable - /// workflow, the repository that *called* the trusted builder. It is filled - /// in by GitHub from the run context and cannot be set by the caller. - final GitHubRepository sourceRepository; - - /// The ref of [sourceRepository] the build ran at, e.g. `refs/tags/v1.0.0`. - final String? sourceRef; - - /// The path of the caller's workflow file. - final String? workflowPath; - - /// The commit the build ran at, if recorded. - final String? commitSha; - - /// The builder as self-reported in the statement. - /// - /// Note that the authoritative statement about who signed is the certificate - /// identity, not this field. - final GitHubWorkflowIdentity? builder; - - final List subjects; - - BuildProvenance({ - required this.sourceRepository, - required this.sourceRef, - required this.workflowPath, - required this.commitSha, - required this.builder, - required this.subjects, - }); - - /// Extracts the build provenance from the DSSE envelope of the Sigstore - /// bundle in [bundleJson]. - /// - /// Throws a [FormatException] if [bundleJson] is not a bundle carrying a - /// SLSA build provenance statement. - /// - /// This does not validate any signature. Only use the result if - /// `package:sigstore` has verified the very same [bundleJson]. - factory BuildProvenance.parseBundle(String bundleJson) { - final Object? decoded; - try { - decoded = json.decode(bundleJson); - } on FormatException catch (e) { - throw FormatException('Attestation is not valid JSON: ${e.message}'); - } - final bundle = _expectMap(decoded, 'bundle'); - final envelope = bundle['dsseEnvelope']; - if (envelope == null) { - throw const FormatException( - 'Attestation does not contain a `dsseEnvelope`, so it carries no ' - 'build provenance.', - ); - } - final dsse = _expectMap(envelope, 'dsseEnvelope'); - final payloadType = dsse['payloadType']; - if (payloadType != _inTotoPayloadType) { - throw FormatException( - 'Attestation payload has type `$payloadType`, ' - 'expected `$_inTotoPayloadType`.', - ); - } - final encodedPayload = dsse['payload']; - if (encodedPayload is! String) { - throw const FormatException('Attestation has no `payload`.'); - } - final Object? decodedStatement; - try { - decodedStatement = json.decode( - utf8.decode(base64.decode(encodedPayload)), - ); - } on FormatException catch (e) { - throw FormatException( - 'Attestation payload is not valid base64-encoded JSON: ${e.message}', - ); - } - final statement = _expectMap(decodedStatement, 'statement'); - - final type = statement['_type']; - if (type != _inTotoStatementType) { - throw FormatException( - 'Attestation statement has type `$type`, ' - 'expected `$_inTotoStatementType`.', - ); - } - final predicateType = statement['predicateType']; - if (predicateType != _slsaProvenancePredicateType) { - throw FormatException( - 'Attestation has predicate type `$predicateType`, ' - 'expected `$_slsaProvenancePredicateType`.', - ); - } - - final subjects = []; - final subjectList = statement['subject']; - if (subjectList is! List || subjectList.isEmpty) { - throw const FormatException('Attestation statement has no `subject`.'); - } - for (final subject in subjectList) { - final s = _expectMap(subject, 'subject'); - final name = s['name']; - if (name is! String) { - throw const FormatException('Attestation subject has no `name`.'); - } - final digest = s['digest']; - final sha256 = digest is Map ? digest['sha256'] : null; - subjects.add( - ProvenanceSubject(name, sha256 is String ? sha256.toLowerCase() : null), - ); - } - - final predicate = _expectMap(statement['predicate'], 'predicate'); - final buildDefinition = _expectMap( - predicate['buildDefinition'], - 'buildDefinition', - ); - final externalParameters = _expectMap( - buildDefinition['externalParameters'], - 'externalParameters', - ); - final workflow = _expectMap(externalParameters['workflow'], 'workflow'); - - final repositoryField = workflow['repository']; - if (repositoryField is! String) { - throw const FormatException( - 'Attestation does not state the repository it was built from.', - ); - } - final sourceRepository = GitHubRepository.tryParse(repositoryField); - if (sourceRepository == null) { - throw FormatException( - 'Attestation was built from `$repositoryField`, which is not a GitHub ' - 'repository. Only GitHub is supported for now.', - ); - } - - String? commitSha; - final resolvedDependencies = buildDefinition['resolvedDependencies']; - if (resolvedDependencies is List) { - for (final dependency in resolvedDependencies) { - if (dependency is! Map) continue; - final uri = dependency['uri']; - if (uri is! String) continue; - if (GitHubRepository.tryParse(uri) != sourceRepository) continue; - final digest = dependency['digest']; - final gitCommit = digest is Map ? digest['gitCommit'] : null; - if (gitCommit is String) { - commitSha = gitCommit; - break; - } - } - } - - GitHubWorkflowIdentity? builder; - final runDetails = predicate['runDetails']; - if (runDetails is Map) { - final builderField = runDetails['builder']; - final id = builderField is Map ? builderField['id'] : null; - if (id is String) builder = GitHubWorkflowIdentity.tryParse(id); - } - - final ref = workflow['ref']; - final path = workflow['path']; - return BuildProvenance( - sourceRepository: sourceRepository, - sourceRef: ref is String ? ref : null, - workflowPath: path is String ? path : null, - commitSha: commitSha, - builder: builder, - subjects: subjects, - ); - } -} - -Map _expectMap(Object? value, String what) { - if (value is! Map) { - throw FormatException('Attestation `$what` is not a JSON object.'); - } - return value; -} diff --git a/lib/src/sigstore/verifier.dart b/lib/src/sigstore/verifier.dart index faca7ccc946..0e1fdf5316b 100644 --- a/lib/src/sigstore/verifier.dart +++ b/lib/src/sigstore/verifier.dart @@ -2,18 +2,18 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -import 'package:collection/collection.dart'; import 'package:crypto/crypto.dart'; import 'package:pub_semver/pub_semver.dart'; +import 'package:sigstore/provenance.dart'; import '../system_cache.dart'; import 'lockfile_policy.dart'; -import 'provenance.dart'; import 'sigstore.dart'; import 'trusted_root.dart'; +export 'package:sigstore/provenance.dart'; + export 'lockfile_policy.dart'; -export 'provenance.dart'; export 'sigstore.dart'; export 'trusted_root.dart'; @@ -205,13 +205,13 @@ class PubAttestationVerifier { final errors = checkProvenancePolicy( packageName: packageName, - packageVersion: packageVersion, + packageVersion: packageVersion.toString(), archiveSha256: Digest(archiveSha256).toString(), provenance: provenance, signer: signer, oidcIssuer: issuer, declaredPackageName: declaredPackageName, - declaredPackageVersion: declaredPackageVersion, + declaredPackageVersion: declaredPackageVersion?.toString(), expectedRepository: expectedRepository, requireRepository: requireRepository, trustedSignerWorkflows: _trustedSignerWorkflows, @@ -289,135 +289,3 @@ class PubAttestationVerifier { } } } - -/// Checks a cryptographically verified attestation against pub's policy, and -/// returns the list of violations - empty if the attestation is acceptable. -/// -/// [archiveSha256] is the lowercase hex-encoded sha256 of the archive, and -/// [signer] the identity from the signing certificate. Both must come from -/// verified material. -/// -/// This is separate from [PubAttestationVerifier.verify] so that the policy can -/// be exercised without doing (or faking) cryptographic verification. -List checkProvenancePolicy({ - required String packageName, - required Version packageVersion, - required String archiveSha256, - required BuildProvenance provenance, - required GitHubWorkflowIdentity signer, - String? oidcIssuer, - String? declaredPackageName, - Version? declaredPackageVersion, - String? expectedRepository, - bool requireRepository = true, - Set trustedSignerWorkflows = defaultTrustedSignerWorkflows, - bool requireTaggedSignerRef = false, -}) { - final errors = []; - - if (oidcIssuer != null && oidcIssuer != githubActionsOidcIssuer) { - errors.add( - 'Attestation was issued by "$oidcIssuer", ' - 'expected "$githubActionsOidcIssuer".', - ); - } - - // Who signed? Only a small set of workflows may sign packages for pub.dev. - final trusted = trustedSignerWorkflows.map((w) => w.toLowerCase()).toSet(); - if (!trusted.contains(signer.workflow.toLowerCase())) { - errors.add( - 'Attestation was signed by "${signer.workflow}", which is not a ' - 'workflow trusted to publish packages. Trusted workflows: ' - '${(trustedSignerWorkflows.toList()..sort()).join(', ')}.', - ); - } else if (requireTaggedSignerRef && !signer.ref.startsWith('refs/tags/')) { - // TODO(https://github.com/dart-lang/pub/issues/4942): Pin the signing - // workflow by the `job_workflow_sha` certificate extension once - // `package:sigstore` exposes it. A ref is mutable, a sha is not. - errors.add( - 'Attestation was signed by "${signer.workflow}" at "${signer.ref}", ' - 'but only tagged releases of the publishing workflow are trusted.', - ); - } else if (!signer.ref.startsWith('refs/tags/') && - !signer.ref.startsWith('refs/heads/')) { - errors.add( - 'Attestation was signed by "${signer.workflow}" at "${signer.ref}", ' - 'which is not a branch or tag ref.', - ); - } - final builder = provenance.builder; - if (builder != null && - builder.workflow.toLowerCase() != signer.workflow.toLowerCase()) { - errors.add( - 'Attestation states it was built by "${builder.workflow}" but was ' - 'signed by "${signer.workflow}".', - ); - } - - // What was signed? Without this an attestation for one package could be - // served for another - all packages share the same signer identity. - // `dart-lang/setup-dart` names the archive `package.tar.gz`, so the package - // name and version are bound through the `pubspec.yaml` inside the archive - // (covered by `archiveSha256`). - final expectedSubject = '$packageName-$packageVersion.tar.gz'; - final subject = - provenance.subjects - .where((s) => s.name == expectedSubject || s.name == 'package.tar.gz') - .firstOrNull; - if (subject == null) { - errors.add( - 'Attestation is for ' - '${provenance.subjects.map((s) => '"${s.name}"').join(', ')}, ' - 'not for "$expectedSubject".', - ); - } else if (subject.sha256 != archiveSha256.toLowerCase()) { - errors.add( - 'Attestation is for an archive with sha256 "${subject.sha256}", ' - 'but the downloaded archive has sha256 "$archiveSha256".', - ); - } - - if (declaredPackageName != null && declaredPackageName != packageName) { - errors.add( - 'The pubspec.yaml of the attested archive declares `name: ' - '$declaredPackageName`, expected `$packageName`.', - ); - } - if (declaredPackageVersion != null && - declaredPackageVersion != packageVersion) { - errors.add( - 'The pubspec.yaml of the attested archive declares `version: ' - '$declaredPackageVersion`, expected `$packageVersion`.', - ); - } - - // Where was it built? This is the binding the whole feature rests on. - if (expectedRepository == null || expectedRepository.trim().isEmpty) { - if (requireRepository) { - errors.add( - 'The pubspec.yaml of $packageName $packageVersion does not have a ' - '`repository` field, so the attestation cannot be bound to the ' - 'repository it claims to come from ' - '("${provenance.sourceRepository.url}").', - ); - } - } else { - final expected = GitHubRepository.tryParse(expectedRepository); - if (expected == null) { - errors.add( - 'Package attestation verification is currently only supported for ' - 'GitHub repositories, but the pubspec.yaml of $packageName ' - '$packageVersion declares `repository: $expectedRepository`.', - ); - } else if (expected != provenance.sourceRepository) { - errors.add( - 'Attestation states the package was built from ' - '"${provenance.sourceRepository.url}", but the pubspec.yaml of ' - '$packageName $packageVersion declares `repository: ' - '$expectedRepository`.', - ); - } - } - - return errors; -} diff --git a/pubspec.lock b/pubspec.lock index b7e7d7aee53..1c5d82bc5e4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + boring: + dependency: transitive + description: + name: boring + sha256: "82d3b79a85dd84ed4b9d935f71fa51884b2e72387eb4c181cdab822f4d723e0f" + url: "https://pub.dev" + source: hosted + version: "0.4.2" + change_case: + dependency: transitive + description: + name: change_case + sha256: e41ef3df58521194ef8d7649928954805aeb08061917cf658322305e61568003 + url: "https://pub.dev" + source: hosted + version: "2.2.0" checks: dependency: "direct dev" description: @@ -69,10 +85,10 @@ packages: dependency: transitive description: name: code_assets - sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + sha256: "828110d598123b5ea96c00c9f3c72105bf79f8ee36c20a39b26209ade421ec57" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "2.1.0" collection: dependency: "direct main" description: @@ -233,6 +249,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "90eaad8059ebc2004a63810a4696ea9acdb02473e47ed3b6b3bafb100ac77e31" + url: "https://pub.dev" + source: hosted + version: "0.19.5" + native_toolchain_cmake: + dependency: transitive + description: + name: native_toolchain_cmake + sha256: "8ba223410102665483e873b83d2156720233a6a0181c07f9dc41cb2e961336b0" + url: "https://pub.dev" + source: hosted + version: "0.3.2" node_preamble: dependency: transitive description: @@ -257,6 +289,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + platform: + dependency: transitive + description: + name: platform + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec + url: "https://pub.dev" + source: hosted + version: "3.2.0" pool: dependency: "direct main" description: @@ -265,6 +305,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.1" + process: + dependency: transitive + description: + name: process + sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891" + url: "https://pub.dev" + source: hosted + version: "5.0.6" pub_semver: dependency: "direct main" description: @@ -324,11 +372,12 @@ packages: sigstore: dependency: "direct main" description: - name: sigstore - sha256: "04c0dda858b534915faa82a9a877e94169c9b9d4546bba5a83f940c78b32736f" - url: "https://pub.dev" - source: hosted - version: "0.1.0" + path: "pkgs/sigstore" + ref: "233e5ab23f23ec1ff52f79a39efb77db74d5c73d" + resolved-ref: "233e5ab23f23ec1ff52f79a39efb77db74d5c73d" + url: "https://github.com/dart-lang/tools.git" + source: git + version: "0.2.5-wip" source_map_stack_trace: dependency: transitive description: @@ -506,4 +555,4 @@ packages: source: hosted version: "2.2.2" sdks: - dart: ">=3.10.0 <4.0.0" + dart: ">=3.13.3 <4.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 3a2e30a9cb2..bc8ad52fbe5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -24,7 +24,13 @@ dependencies: pool: ^1.0.0 pub_semver: ^2.2.0 shelf: ^1.4.2 - sigstore: ^0.1.0 + # TODO(https://github.com/dart-lang/tools/pull/2710): Depend on the hosted + # `^0.2.5` once `package:sigstore/provenance.dart` is published. + sigstore: + git: + url: https://github.com/dart-lang/tools.git + ref: 233e5ab23f23ec1ff52f79a39efb77db74d5c73d + path: pkgs/sigstore source_span: ^1.10.2 stack_trace: ^1.12.1 tar: ^2.0.0 diff --git a/test/sigstore/policy_test.dart b/test/sigstore/policy_test.dart deleted file mode 100644 index 6141cea7992..00000000000 --- a/test/sigstore/policy_test.dart +++ /dev/null @@ -1,173 +0,0 @@ -// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file -// for details. All rights reserved. Use of this source code is governed by a -// BSD-style license that can be found in the LICENSE file. - -@TestOn('vm') -library; - -import 'package:pub/src/sigstore/verifier.dart'; -import 'package:pub_semver/pub_semver.dart'; -import 'package:test/test.dart'; - -import 'test_fixtures.dart'; - -const _trustedSigner = - 'https://github.com/dart-lang/setup-dart/.github/workflows/' - 'publish.yml@refs/tags/v2'; - -const _archiveSha256 = - '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08'; - -/// Runs the policy for `helpful 1.0.0`, published from `dieter/helpful`. -List check({ - String packageName = 'helpful', - String version = '1.0.0', - String archiveSha256 = _archiveSha256, - String subjectName = 'helpful-1.0.0.tar.gz', - String subjectSha256 = _archiveSha256, - String repository = 'https://github.com/dieter/helpful', - String? builderId = _trustedSigner, - String signerIdentity = _trustedSigner, - String? oidcIssuer = githubActionsOidcIssuer, - String? expectedRepository = 'https://github.com/dieter/helpful', - bool requireRepository = true, - bool requireTaggedSignerRef = true, -}) { - return checkProvenancePolicy( - packageName: packageName, - packageVersion: Version.parse(version), - archiveSha256: archiveSha256, - provenance: BuildProvenance.parseBundle( - buildProvenanceBundleJson( - subjectName: subjectName, - subjectSha256: subjectSha256, - repository: repository, - builderId: builderId, - ), - ), - signer: GitHubWorkflowIdentity.tryParse(signerIdentity)!, - oidcIssuer: oidcIssuer, - expectedRepository: expectedRepository, - requireRepository: requireRepository, - requireTaggedSignerRef: requireTaggedSignerRef, - ); -} - -void main() { - test( - 'accepts a package built by the trusted workflow from its repository', - () { - expect(check(), isEmpty); - }, - ); - - group('signer', () { - test('rejects a signature from an untrusted workflow', () { - // This is the case the client used to accept: a cryptographically valid - // bundle from any GitHub Actions workflow in any repository. - final errors = check( - signerIdentity: - 'https://github.com/evil/evil/.github/workflows/sign.yml@' - 'refs/tags/v1', - builderId: - 'https://github.com/evil/evil/.github/workflows/sign.yml@' - 'refs/tags/v1', - ); - expect(errors, hasLength(1)); - expect(errors.single, contains('not a workflow trusted to publish')); - }); - - test('rejects the trusted workflow at a mutable ref', () { - final errors = check( - signerIdentity: - 'https://github.com/dart-lang/setup-dart/.github/workflows/' - 'publish.yml@refs/heads/main', - builderId: - 'https://github.com/dart-lang/setup-dart/.github/workflows/' - 'publish.yml@refs/heads/main', - ); - expect(errors.single, contains('only tagged releases')); - }); - - test('rejects a builder that disagrees with the signer', () { - final errors = check( - builderId: - 'https://github.com/evil/evil/.github/workflows/sign.yml@' - 'refs/tags/v1', - ); - expect(errors.single, contains('but was signed by')); - }); - - test('rejects a non-GitHub issuer', () { - final errors = check(oidcIssuer: 'https://accounts.google.com'); - expect(errors.single, contains('was issued by')); - }); - }); - - group('subject', () { - test('rejects an attestation for another package', () { - // All packages are signed by the same workflow, so without this check a - // compromised repository could serve the archive and attestation of one - // package under the name of another. - final errors = check(subjectName: 'evil-1.0.0.tar.gz'); - expect(errors.single, contains('not for "helpful-1.0.0.tar.gz"')); - }); - - test('rejects an attestation for another version', () { - final errors = check(subjectName: 'helpful-1.0.1.tar.gz'); - expect(errors.single, contains('not for "helpful-1.0.0.tar.gz"')); - }); - - test('rejects an attestation for other bytes', () { - final errors = check(subjectSha256: 'a' * 64); - expect(errors.single, contains('but the downloaded archive has sha256')); - }); - }); - - group('repository', () { - test('rejects a build from another repository', () { - final errors = check(repository: 'https://github.com/evil/helpful'); - expect(errors.single, contains('was built from')); - }); - - test('rejects a repository that only matches by suffix', () { - final errors = check( - repository: 'https://github.com/evil-dieter/helpful', - ); - expect(errors.single, contains('was built from')); - }); - - test('accepts a monorepo subdirectory url', () { - expect( - check( - repository: 'https://github.com/dart-lang/tools', - expectedRepository: - 'https://github.com/dart-lang/tools/tree/main/pkgs/helpful', - ), - isEmpty, - ); - }); - - test('accepts a differently spelled url for the same repository', () { - expect( - check(expectedRepository: 'https://github.com/Dieter/helpful.git'), - isEmpty, - ); - }); - - test('rejects a package that does not declare a repository', () { - // Without a repository in the pubspec the attestation is unbound: it - // only says that some trusted workflow built these bytes. - final errors = check(expectedRepository: null); - expect(errors.single, contains('does not have a `repository` field')); - expect(check(expectedRepository: ' '), hasLength(1)); - }); - - test('rejects a non-GitHub repository in the pubspec', () { - final errors = check( - expectedRepository: 'https://gitlab.com/dieter/helpful', - ); - expect(errors.single, contains('only supported for GitHub')); - }); - }); -} diff --git a/test/sigstore/provenance_test.dart b/test/sigstore/provenance_test.dart deleted file mode 100644 index 3d90ffa21f6..00000000000 --- a/test/sigstore/provenance_test.dart +++ /dev/null @@ -1,195 +0,0 @@ -// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file -// for details. All rights reserved. Use of this source code is governed by a -// BSD-style license that can be found in the LICENSE file. - -@TestOn('vm') -library; - -import 'package:pub/src/sigstore/provenance.dart'; -import 'package:test/test.dart'; - -import 'test_fixtures.dart'; - -void main() { - group('GitHubRepository.tryParse', () { - void expectRepo(String source, String? slug) { - expect( - GitHubRepository.tryParse(source)?.slug, - slug, - reason: 'parsing "$source"', - ); - } - - test('accepts the forms found in pubspecs and provenance', () { - expectRepo('https://github.com/dieter/helpful', 'dieter/helpful'); - expectRepo('https://github.com/dieter/helpful/', 'dieter/helpful'); - expectRepo('https://github.com/dieter/helpful.git', 'dieter/helpful'); - expectRepo('http://github.com/dieter/helpful', 'dieter/helpful'); - expectRepo('https://www.github.com/dieter/helpful', 'dieter/helpful'); - expectRepo(' https://github.com/dieter/helpful ', 'dieter/helpful'); - expectRepo( - 'git+https://github.com/dieter/helpful@refs/tags/v1.0.0', - 'dieter/helpful', - ); - }); - - test('ignores the subdirectory of a monorepo package', () { - expectRepo( - 'https://github.com/dart-lang/tools/tree/main/pkgs/graphs', - 'dart-lang/tools', - ); - }); - - test('rejects everything that is not a GitHub repository', () { - expectRepo('https://gitlab.com/dieter/helpful', null); - expectRepo('https://example.com/dieter/helpful', null); - // A suffix check on the host would accept this one. - expectRepo('https://github.com.evil.example/dieter/helpful', null); - expectRepo('https://notgithub.com/dieter/helpful', null); - expectRepo('https://github.com/dieter', null); - expectRepo('https://github.com/', null); - expectRepo('ftp://github.com/dieter/helpful', null); - expectRepo('dieter/helpful', null); - expectRepo('', null); - }); - - test('compares case-insensitively but does not match by suffix', () { - final helpful = GitHubRepository.tryParse( - 'https://github.com/dieter/helpful', - ); - expect( - helpful, - GitHubRepository.tryParse('https://github.com/Dieter/Helpful.git'), - ); - // `endsWith`-style matching would consider these equal. - expect( - helpful, - isNot( - GitHubRepository.tryParse('https://github.com/evil-dieter/helpful'), - ), - ); - expect( - helpful, - isNot(GitHubRepository.tryParse('https://github.com/evil/helpful')), - ); - }); - }); - - group('GitHubWorkflowIdentity.tryParse', () { - test('splits repository, path and ref', () { - final identity = - GitHubWorkflowIdentity.tryParse( - 'https://github.com/dart-lang/setup-dart/.github/workflows/' - 'publish.yml@refs/tags/v2', - )!; - expect(identity.repository.slug, 'dart-lang/setup-dart'); - expect(identity.path, '.github/workflows/publish.yml'); - expect(identity.ref, 'refs/tags/v2'); - expect( - identity.workflow, - 'dart-lang/setup-dart/.github/workflows/publish.yml', - ); - }); - - test('rejects identities without a workflow path or ref', () { - expect( - GitHubWorkflowIdentity.tryParse( - 'https://github.com/dart-lang/setup-dart@refs/tags/v2', - ), - isNull, - ); - expect( - GitHubWorkflowIdentity.tryParse( - 'https://github.com/dart-lang/setup-dart/.github/workflows/p.yml', - ), - isNull, - ); - expect( - GitHubWorkflowIdentity.tryParse('mailto:someone@example.com'), - isNull, - ); - }); - }); - - group('BuildProvenance.parseBundle', () { - test('extracts the repository, ref, commit and subjects', () { - final provenance = BuildProvenance.parseBundle( - buildProvenanceBundleJson( - subjectName: 'helpful-0.1.2.tar.gz', - subjectSha256: 'abc123', - ref: 'refs/tags/v0.1.2', - commitSha: 'deadbeef', - ), - ); - expect(provenance.sourceRepository.slug, 'dieter/helpful'); - expect(provenance.sourceRef, 'refs/tags/v0.1.2'); - expect(provenance.commitSha, 'deadbeef'); - expect(provenance.subjects.single.name, 'helpful-0.1.2.tar.gz'); - expect(provenance.subjects.single.sha256, 'abc123'); - expect( - provenance.builder!.workflow, - 'dart-lang/setup-dart/.github/workflows/publish.yml', - ); - }); - - test('rejects a bundle without a DSSE envelope', () { - // The bundle of a plain artifact signature carries no statement at all - - // there is nothing to bind it to a package. - expect( - () => BuildProvenance.parseBundle(sampleBundleJson), - throwsA( - isA().having( - (e) => e.message, - 'message', - contains('carries no build provenance'), - ), - ), - ); - }); - - test('rejects a statement that is not SLSA build provenance', () { - expect( - () => BuildProvenance.parseBundle( - buildProvenanceBundleJson( - predicateType: 'https://slsa.dev/verification_summary/v1', - ), - ), - throwsA(isA()), - ); - expect( - () => BuildProvenance.parseBundle( - buildProvenanceBundleJson(payloadType: 'application/json'), - ), - throwsA(isA()), - ); - }); - - test('rejects a build from a non-GitHub repository', () { - expect( - () => BuildProvenance.parseBundle( - buildProvenanceBundleJson( - repository: 'https://gitlab.com/dieter/helpful', - ), - ), - throwsA( - isA().having( - (e) => e.message, - 'message', - contains('not a GitHub repository'), - ), - ), - ); - }); - - test('rejects malformed input', () { - expect( - () => BuildProvenance.parseBundle('not json'), - throwsA(isA()), - ); - expect( - () => BuildProvenance.parseBundle('{"dsseEnvelope": 42}'), - throwsA(isA()), - ); - }); - }); -} From c7d67a5b3e74ba4d5c7bdd35fb24c1c197bf8d0a Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 23 Oct 2026 11:47:42 +0000 Subject: [PATCH 12/12] Expect attestations per version: lockfile provenance and a versioned cache record Address the review of the client-side policy: - The per-package record of a seen attestation in the pub cache gets a version dimension: it stores the lowest attested version seen, and an attestation is expected for that version and later ones only. Versions before the first attested one stay installable, so `dart pub downgrade`, an older pin in another project and `dart pub cache repair` keep working. The record is JSON ({repository, since}); a malformed record is ignored. - A `provenance` in pubspec.lock for the version being downloaded also means an attestation is expected - the signal that follows the project to other machines - and the download fails if there is none, instead of the stale lockfile value silently surviving an unverified download. withProvenance now records exactly what the download established. - ProvenancePolicy.check replaces enforcePolicy: it takes an AttestationExpectation (LockedProvenance or PreviouslyAttested) that says where the expectation comes from, and the error names the package repository and tells the user what to do (drop the lockfile entry, or delete the named cache record). Nothing is called a downgrade anymore. - The repository spec says what the client check proves (built by the trusted workflow from the repository the archive itself names, not that this repository is authorized for the package), and when an attestation is expected. --- doc/repository-spec-v2.md | 39 ++++ lib/src/sigstore/lockfile_policy.dart | 159 ++++++++++++---- lib/src/source/hosted.dart | 124 +++++++++---- .../hosted/attestation_verification_test.dart | 169 ++++++++++++++++-- test/sigstore/lockfile_policy_test.dart | 161 +++++++++++++---- 5 files changed, 523 insertions(+), 129 deletions(-) diff --git a/doc/repository-spec-v2.md b/doc/repository-spec-v2.md index ad960395b07..dc97f04e497 100644 --- a/doc/repository-spec-v2.md +++ b/doc/repository-spec-v2.md @@ -323,6 +323,45 @@ The attestation is not a substitute for the repository being trustworthy about *which* versions are attested; it is what allows the client to check the archive without trusting the repository about its *contents*. +#### What the client verifies + +For an attested version the client checks that: + +* the Sigstore bundle verifies against the Sigstore public-good trusted root, + and was signed by a trusted publishing workflow (currently + `dart-lang/setup-dart/.github/workflows/publish.yml`); +* the SLSA build provenance it carries names the downloaded archive by its + sha256; and +* the repository the provenance says the archive was built from is the + `repository` of the `pubspec.yaml` *inside* the archive. + +A passing verification thus proves that the archive was built, unmodified, by +the trusted workflow from the GitHub repository that the archive itself names. +It does **not** prove that this repository is the one authorized to publish the +package: that binding is established by the package repository at publish time, +when it accepts (or rejects) the upload. The client records the verified +repository in `pubspec.lock` (`provenance`) and warns when a later version of +the package was built from another repository; it cannot tell a legitimate move +from a hijack. + +#### When the client expects an attestation + +The client fetches and verifies an attestation, and fails the download if there +is none, when any of the following holds: + +* the version listing reports `slsa_level` for the version; +* `pubspec.lock` records a `provenance` for the version, i.e. it was verified + when the lockfile was last resolved — the only signal that follows the project + to other machines; or +* an attestation has been verified for the same or an earlier version of the + package on this machine. Versions before the first attested one are not + expected to be attested, so they stay installable (`dart pub downgrade`, an + older pin in another project). + +A repository that attests a package should therefore keep attesting every later +version of it; clients that have verified one version will reject later +unattested ones. + ## Publishing Packages **GET** `/api/packages/versions/new` diff --git a/lib/src/sigstore/lockfile_policy.dart b/lib/src/sigstore/lockfile_policy.dart index a89b1fb077a..1825aab6144 100644 --- a/lib/src/sigstore/lockfile_policy.dart +++ b/lib/src/sigstore/lockfile_policy.dart @@ -54,61 +54,142 @@ class ProvenanceInfo { int get hashCode => Object.hash(repository, ref, commitSha, workflowPath); } -/// Enforces security policies between the current package and previous locks. +/// Why an attestation is expected for a version of a package, and from which +/// repository the package is expected to have been built. +/// +/// The package repository says which versions are attested (`slsa_level` in +/// the version listing), but must not be the only one to say so: a compromised +/// repository could simply stop reporting it. These are the two signals the +/// client has of its own. +sealed class AttestationExpectation { + /// The repository the package is expected to have been built from. + String get repository; + + /// Where the expectation comes from, as a sentence for a message about + /// [package] [version]. + String describe(String package, Version version); + + /// What to do if [package] is not attested on purpose, as a sentence. + String howToDrop(String package); +} + +/// `pubspec.lock` records that this version was verified as built from +/// [repository] when it was last resolved. +/// +/// This is the signal that travels with the project: a machine that has never +/// downloaded the package (say, a fresh CI runner) expects the attestation that +/// the machine that resolved the lockfile verified, and dropping it is a change +/// to the lockfile that shows up in code review. +final class LockedProvenance extends AttestationExpectation { + @override + final String repository; + + LockedProvenance(this.repository); + + @override + String describe(String package, Version version) => + 'pubspec.lock records that $package $version was verified as built from ' + '$repository when it was last resolved.'; + + @override + String howToDrop(String package) => + 'If $package is no longer attested on purpose, remove the `provenance` ' + 'entry of $package from pubspec.lock to accept it unverified.'; +} + +/// An attestation was verified for a version of this package on this machine, +/// which is recorded in the pub cache (`HostedSource.packageProvenancePath`). +/// +/// Versions from [since], the lowest attested version seen, on are expected to +/// be attested. Earlier versions are not: they may predate the first attested +/// version of the package, and must stay installable, for `dart pub downgrade` +/// or an older pin in another project on the same machine. +final class PreviouslyAttested extends AttestationExpectation { + @override + final String repository; + + /// The lowest version an attestation was verified for. + final Version since; + + /// The file in the pub cache recording this, so the message can name it. + final String path; + + PreviouslyAttested({ + required this.repository, + required this.since, + required this.path, + }); + + @override + String describe(String package, Version version) => + 'an attestation from $repository was verified for $package $since on ' + 'this machine, so versions from $since on are expected to be attested.'; + + @override + String howToDrop(String package) => + 'If $package is no longer attested on purpose, delete `$path` to stop ' + 'expecting attestations for it on this machine.'; +} + +/// The client-side policy for the provenance of downloaded packages. +/// +/// What a verified attestation proves is that the archive was built by a +/// trusted publishing workflow from the GitHub repository the archive's own +/// pubspec names. Whether that repository is the one authorized to publish the +/// package is established by the package repository at publish time, not here: +/// the client can only check that an attestation is there when one is expected, +/// and notice when the repository changes. class ProvenancePolicy { - /// Enforces downgrade prevention and repository continuity. + /// Checks [verified], the provenance established by verifying the attestation + /// of [package] [version] (`null` if it was not attested), against + /// [expectation] (`null` if no attestation was expected). /// - /// 1. Downgrade attack prevention: If [packageName] was previously locked - /// with signed provenance ([previousLockedProvenance] != null), - /// subsequent versions must also have valid signed provenance - /// ([currentProvenance] != null). - /// - /// 2. Repository switch alert: If the repository changes from - /// [previousLockedProvenance], a security warning or error is issued. - static void enforcePolicy({ - required String packageName, + /// Throws a [PackageProvenanceException] if an attestation was expected and + /// there is none, naming [repositoryUrl], the package repository, and what to + /// do about it. Reports a package built from another repository than + /// expected to [onRepositoryChange], or throws if [fatalOnRepositoryChange]. + static void check({ + required String package, required Version version, - required ProvenanceInfo? currentProvenance, - required ProvenanceInfo? previousLockedProvenance, - bool fatalOnRepoMismatch = false, - void Function(String message)? onWarning, + required ProvenanceInfo? verified, + required AttestationExpectation? expectation, + required String repositoryUrl, + bool fatalOnRepositoryChange = false, + void Function(String message)? onRepositoryChange, }) { - if (previousLockedProvenance != null && currentProvenance == null) { + if (expectation == null) return; + + if (verified == null) { throw PackageProvenanceException(''' -Package "$packageName" version $version is not signed with provenance. -However, previously locked versions of this package were signed by: - ${previousLockedProvenance.repository} +Package $package $version is not attested, but an attestation is expected: +${expectation.describe(package, version)} -Downgrading from a signed package to an unsigned package is prohibited. +The package repository `$repositoryUrl` served no attestation for it. +${expectation.howToDrop(package)} '''); } - if (previousLockedProvenance != null && currentProvenance != null) { - if (!_repositoriesMatch( - previousLockedProvenance.repository, - currentProvenance.repository, - )) { - final message = - 'Package "$packageName" provenance repository changed from ' - '"${previousLockedProvenance.repository}" to ' - '"${currentProvenance.repository}".'; - if (fatalOnRepoMismatch) { - throw PackageProvenanceException( - '$message\n' - 'Repository changes for signed packages require verification.', - ); - } else if (onWarning != null) { - onWarning(message); - } + if (!repositoriesMatch(expectation.repository, verified.repository)) { + final message = + 'Package $package $version is attested as built from ' + '${verified.repository}, but ' + '${expectation.describe(package, version)}'; + if (fatalOnRepositoryChange) { + throw PackageProvenanceException(message); } + onRepositoryChange?.call(message); } } - static bool _repositoriesMatch(String a, String b) { + /// Whether [a] and [b] name the same repository. + /// + /// GitHub repositories compare by owner and name, case-insensitively and + /// regardless of the URL form (see [GitHubRepository.tryParse]); anything + /// else compares as a string. + static bool repositoriesMatch(String a, String b) { final repoA = GitHubRepository.tryParse(a); final repoB = GitHubRepository.tryParse(b); if (repoA != null && repoB != null) return repoA == repoB; - // Not (both) GitHub repositories - fall back to exact comparison. return a.trim().toLowerCase() == b.trim().toLowerCase(); } } diff --git a/lib/src/source/hosted.dart b/lib/src/source/hosted.dart index 721d62da26a..806ab599e30 100644 --- a/lib/src/source/hosted.dart +++ b/lib/src/source/hosted.dart @@ -1428,28 +1428,41 @@ class HostedSource extends CachedSource { } } - /// Writes the provenance repository for [id] in the cache. + /// Writes the verified provenance repository for [id] in the cache, and + /// records that the package is attested from [id]'s version on (see + /// [packageProvenancePath]). void writeProvenance(PackageId id, SystemCache cache, String provenance) { final path = provenancePath(id, cache); ensureDir(p.dirname(path)); writeTextFile(path, provenance); - // Also remember, independently of the version, that this package was - // attested at least once. See [packageProvenancePath]. - final packagePath = packageProvenancePath( - id.description.description as HostedDescription, - cache, - ); + + final description = id.description.description as HostedDescription; + final previous = packageProvenanceFromCache(description, cache); + // The lowest attested version seen: a version attested later than a + // higher one lowers the threshold, it never raises it. + final since = + previous != null && previous.since < id.version + ? previous.since + : id.version; + final packagePath = packageProvenancePath(description, cache); ensureDir(p.dirname(packagePath)); - writeTextFile(packagePath, provenance); + writeTextFile( + packagePath, + jsonEncode({'repository': provenance, 'since': since.toString()}), + ); } /// Next to the per-version provenance files there is a per-package file - /// recording the repository the package was last attested from. + /// recording the repository the package was last attested from, and the + /// lowest version an attestation was verified for. /// /// Whether an archive is verified must not be decided by the package /// repository alone: a compromised repository could simply stop reporting - /// `slsa_level` for a package. Once we have seen an attestation for any - /// version of a package, we keep requiring one. + /// `slsa_level` for a package. Once an attestation has been verified for a + /// version of a package, one is expected for that version and every later + /// one. Earlier versions may predate the first attested version and stay + /// installable, so that `dart pub downgrade`, an older pin in another + /// project and `dart pub cache repair` keep working. /// /// Package names cannot contain `-`, so this never collides with the /// `-.provenance` files. @@ -1465,19 +1478,54 @@ class HostedSource extends CachedSource { ); } - /// Loads the repository of the last attestation seen for the package - /// described by [description], or `null` if it was never attested. - String? packageProvenanceFromCache( + /// Loads what [packageProvenancePath] records for the package described by + /// [description], or `null` if no attestation was verified for it on this + /// machine (or the record cannot be read). + ({String repository, Version since})? packageProvenanceFromCache( HostedDescription description, SystemCache cache, ) { + final path = packageProvenancePath(description, cache); + final String text; try { - final text = - readTextFile(packageProvenancePath(description, cache)).trim(); - return text.isEmpty ? null : text; + text = readTextFile(path); } on io.IOException { return null; } + try { + final json = jsonDecode(text); + if (json case { + 'repository': final String repository, + 'since': final String since, + } when repository.isNotEmpty) { + return (repository: repository, since: Version.parse(since)); + } + } on FormatException { + // Fall through. + } + log.fine('Ignoring malformed provenance record at $path.'); + return null; + } + + /// Why an attestation is expected for [id], if it is: because `pubspec.lock` + /// records a verified provenance for this version, or because one was + /// verified for this or an earlier version of the package on this machine. + AttestationExpectation? attestationExpectation( + PackageId id, + SystemCache cache, + ) { + final description = id.description as ResolvedHostedDescription; + final locked = description.provenance; + if (locked != null) return LockedProvenance(locked); + final previous = packageProvenanceFromCache(description.description, cache); + if (previous != null && id.version >= previous.since) { + return PreviouslyAttested( + repository: previous.repository, + since: previous.since, + path: packageProvenancePath(description.description, cache), + ); + } + return null; } /// Re-downloads all packages that have been previously downloaded into the @@ -1724,14 +1772,13 @@ class HostedSource extends CachedSource { Uint8List? contentHash; String? attestationJson; - // The repository we have previously seen an attestation from for this - // package, if any. - final previousProvenance = packageProvenanceFromCache(description, cache); // Attestations are fetched when the repository says there is one, but also - // when we have seen one before: the repository must not be able to skip + // when the lockfile records one for this version or one has been verified + // for the package on this machine: the repository must not be able to skip // verification by simply not reporting `slsa_level`. + final expectation = attestationExpectation(id, cache); final expectAttestation = - versionInfo.slsaLevel != null || previousProvenance != null; + versionInfo.slsaLevel != null || expectation != null; final archiveUrl = versionInfo.archiveUrl; log.io('Get package from $archiveUrl.'); @@ -1839,7 +1886,7 @@ See $contentHashesDocumentationUrl. archiveSha256: contentHash!, extractedPath: destPath, slsaLevel: versionInfo.slsaLevel, - previousProvenance: previousProvenance, + expectation: expectation, ); if (verifiedProvenance != null) { writeProvenance(id, cache, verifiedProvenance); @@ -1914,7 +1961,7 @@ with a proxy in front of it that does not forward attestation requests. required Uint8List archiveSha256, required String extractedPath, required int? slsaLevel, - required String? previousProvenance, + required AttestationExpectation? expectation, }) { AttestationVerificationResult? result; if (attestationJson != null) { @@ -1968,17 +2015,15 @@ This indicates a problem on the package repository: `${description.url}`. '''); } - // Throws if a previously attested package is no longer attested, and warns - // if it changed repository. - ProvenancePolicy.enforcePolicy( - packageName: id.name, + // Throws if an attestation was expected and there is none, and warns if + // the package was built from another repository than expected. + ProvenancePolicy.check( + package: id.name, version: id.version, - currentProvenance: result?.provenance, - previousLockedProvenance: - previousProvenance == null - ? null - : ProvenanceInfo(repository: previousProvenance), - onWarning: log.warning, + verified: result?.provenance, + expectation: expectation, + repositoryUrl: description.url, + onRepositoryChange: log.warning, ); return result?.repository; } @@ -2339,15 +2384,18 @@ class ResolvedHostedDescription extends ResolvedDescription { provenance: provenance, ); - /// Returns a description with [newProvenance], if it is not `null`. + /// Returns a description with [newProvenance] as its provenance. /// - /// A known provenance is never cleared: that would silently drop the - /// repository binding of an already verified package. + /// `null` clears a known provenance: the lockfile records what the download + /// established, and a package that was downloaded unverified must not keep + /// asserting a repository binding. (A download that lacks an attestation + /// the lockfile records fails instead, see + /// [HostedSource.attestationExpectation].) ResolvedHostedDescription withProvenance(String? newProvenance) => ResolvedHostedDescription( description, sha256: sha256, - provenance: newProvenance ?? provenance, + provenance: newProvenance, ); } diff --git a/test/get/hosted/attestation_verification_test.dart b/test/get/hosted/attestation_verification_test.dart index 83891d1510c..87f955ac3df 100644 --- a/test/get/hosted/attestation_verification_test.dart +++ b/test/get/hosted/attestation_verification_test.dart @@ -5,6 +5,7 @@ @TestOn('vm') library; +import 'dart:convert'; import 'dart:io'; import 'package:path/path.dart' as p; @@ -59,28 +60,125 @@ void main() { versionProvenance.readAsStringSync(), 'https://github.com/mosuem/helpful', ); + expect(jsonDecode(packageProvenance.readAsStringSync()), { + 'repository': 'https://github.com/mosuem/helpful', + 'since': '0.1.5', + }); + + final lockfilePath = p.join(d.sandbox, appPath, 'pubspec.lock'); expect( - packageProvenance.readAsStringSync(), - 'https://github.com/mosuem/helpful', + File(lockfilePath).readAsStringSync(), + contains('provenance: "https://github.com/mosuem/helpful"'), ); - final lockfile = - File(p.join(d.sandbox, appPath, 'pubspec.lock')).readAsStringSync(); + // An older unattested version stays installable: it predates the first + // attested version, and `dart pub downgrade` must keep working. + server.serve('helpful', '0.1.4'); + await pubDowngrade(); expect( - lockfile, - contains('provenance: "https://github.com/mosuem/helpful"'), + Directory(server.pathInCache('helpful', '0.1.4')).existsSync(), + isTrue, + ); + expect( + File(lockfilePath).readAsStringSync(), + isNot(contains('provenance')), ); - // Serving a newer unattested version of the same package must be - // rejected as a downgrade even if the lockfile is deleted. - File(p.join(d.sandbox, appPath, 'pubspec.lock')).deleteSync(); + // A newer unattested version is rejected, even without a lockfile: an + // attestation has been verified for 0.1.5 on this machine. + File(lockfilePath).deleteSync(); server.serve('helpful', '0.1.6'); await pubGet( - error: contains('Downgrading from a signed package'), + error: allOf( + contains( + 'helpful 0.1.6 is not attested, but an attestation is ' + 'expected', + ), + contains('versions from 0.1.5 on are expected to be attested'), + contains(packageProvenance.path), + ), exitCode: DATA, ); }); + test( + 'expects the attestation that the lockfile records, on any machine', + () async { + final server = await servePackages(); + server.serve( + 'helpful', + '0.1.5', + slsaLevel: 2, + archiveBytes: helpfulArchiveBytes, + pubspec: {'repository': 'https://github.com/mosuem/helpful'}, + ); + const attestationPath = + '/api/packages/helpful/versions/0.1.5/attestation'; + server.handle(attestationPath, (request) { + return Response.ok( + helpfulBundleJson, + headers: {'content-type': 'application/json; charset="utf-8"'}, + ); + }); + await d + .appDir( + dependencies: { + 'helpful': {'hosted': server.url, 'version': '^0.1.0'}, + }, + ) + .create(); + await pubGet(); + final lockfilePath = p.join(d.sandbox, appPath, 'pubspec.lock'); + expect( + File(lockfilePath).readAsStringSync(), + contains('provenance: "https://github.com/mosuem/helpful"'), + ); + + // Another machine: nothing in the pub cache, the same lockfile. And the + // repository no longer attests the version. + Directory( + server.pathInCache('helpful', '0.1.5'), + ).deleteSync(recursive: true); + Directory(server.provenanceCachingPath).deleteSync(recursive: true); + server.serve( + 'helpful', + '0.1.5', + archiveBytes: helpfulArchiveBytes, + pubspec: {'repository': 'https://github.com/mosuem/helpful'}, + ); + server.handle(attestationPath, (request) => Response.notFound('gone')); + + await pubGet( + error: allOf( + contains( + 'helpful 0.1.5 is not attested, but an attestation is ' + 'expected', + ), + contains( + 'pubspec.lock records that helpful 0.1.5 was verified as ' + 'built from https://github.com/mosuem/helpful', + ), + contains( + 'remove the `provenance` entry of helpful from pubspec.lock', + ), + ), + exitCode: DATA, + ); + + // Dropping the expectation is an edit to the lockfile, visible in review. + // The unverified download then records no provenance. + final lockfile = File(lockfilePath); + lockfile.writeAsStringSync( + lockfile + .readAsLinesSync() + .where((line) => !line.trim().startsWith('provenance:')) + .join('\n'), + ); + await pubGet(); + expect(lockfile.readAsStringSync(), isNot(contains('provenance'))); + }, + ); + test('proceeds when no attestation is served by repository', () async { final server = await servePackages(); server.serve('foo', '1.0.0'); @@ -179,9 +277,10 @@ void main() { test( 'pub get fails when a previously attested package is no longer attested', () async { - // The repository does not report `slsa_level` for this version, but we - // have seen an attestation for this package before. A repository must - // not be able to turn verification off. + // The repository does not report `slsa_level` for this version, but an + // attestation has been verified for an earlier version of the package + // on this machine. A repository must not be able to turn verification + // off. final server = await servePackages(); server.serve('foo', '1.0.0'); @@ -197,12 +296,52 @@ void main() { p.join(server.provenanceCachingPath, 'foo.provenance'), ); marker.parent.createSync(recursive: true); - marker.writeAsStringSync('https://github.com/dieter/helpful'); + marker.writeAsStringSync( + jsonEncode({ + 'repository': 'https://github.com/dieter/helpful', + 'since': '0.9.0', + }), + ); await pubGet( - error: contains('Downgrading from a signed package'), + error: allOf( + contains('foo 1.0.0 is not attested, but an attestation is expected'), + contains('verified for foo 0.9.0 on this machine'), + contains('delete `${marker.path}`'), + ), exitCode: DATA, ); + + // Versions before the first attested one are not expected to be + // attested. + marker.writeAsStringSync( + jsonEncode({ + 'repository': 'https://github.com/dieter/helpful', + 'since': '2.0.0', + }), + ); + await pubGet(); + expect( + Directory(server.pathInCache('foo', '1.0.0')).existsSync(), + isTrue, + ); }, ); + + test('ignores a malformed provenance record', () async { + final server = await servePackages(); + server.serve('foo', '1.0.0'); + await d + .appDir( + dependencies: { + 'foo': {'hosted': server.url, 'version': '^1.0.0'}, + }, + ) + .create(); + final marker = File(p.join(server.provenanceCachingPath, 'foo.provenance')); + marker.parent.createSync(recursive: true); + marker.writeAsStringSync('https://github.com/dieter/helpful'); + + await pubGet(); + }); } diff --git a/test/sigstore/lockfile_policy_test.dart b/test/sigstore/lockfile_policy_test.dart index ce58ec16e28..43b42c8b9c7 100644 --- a/test/sigstore/lockfile_policy_test.dart +++ b/test/sigstore/lockfile_policy_test.dart @@ -9,64 +9,151 @@ import 'package:pub/src/sigstore/verifier.dart'; import 'package:pub_semver/pub_semver.dart'; import 'package:test/test.dart'; +const _repository = 'https://github.com/mosuem/helpful'; +const _markerPath = '/pub-cache/hosted-provenance/pub.dev/helpful.provenance'; + +final _locked = LockedProvenance(_repository); +final _previouslyAttested = PreviouslyAttested( + repository: _repository, + since: Version(0, 1, 3), + path: _markerPath, +); + +void check({ + required ProvenanceInfo? verified, + required AttestationExpectation? expectation, + bool fatalOnRepositoryChange = false, + void Function(String message)? onRepositoryChange, +}) { + ProvenancePolicy.check( + package: 'helpful', + version: Version(0, 1, 4), + verified: verified, + expectation: expectation, + repositoryUrl: 'https://pub.dev', + fatalOnRepositoryChange: fatalOnRepositoryChange, + onRepositoryChange: onRepositoryChange, + ); +} + void main() { - test('allows signed package when previously locked version was signed', () { - final prev = ProvenanceInfo( - repository: 'https://github.com/mosuem/helpful', - ref: 'refs/tags/v0.1.3', - ); - final current = ProvenanceInfo( - repository: 'https://github.com/mosuem/helpful', - ref: 'refs/tags/v0.1.4', - ); + test('accepts an unattested package when none is expected', () { + expect(() => check(verified: null, expectation: null), returnsNormally); + }); + test('accepts an attested package when none is expected', () { expect( - () => ProvenancePolicy.enforcePolicy( - packageName: 'helpful', - version: Version(0, 1, 4), - currentProvenance: current, - previousLockedProvenance: prev, + () => check( + verified: ProvenanceInfo(repository: _repository), + expectation: null, ), returnsNormally, ); }); - test('blocks downgrade attack when new version is unsigned', () { - final prev = ProvenanceInfo( - repository: 'https://github.com/mosuem/helpful', - ref: 'refs/tags/v0.1.3', + test('accepts an attested package from the expected repository', () { + for (final expectation in [_locked, _previouslyAttested]) { + expect( + () => check( + verified: ProvenanceInfo(repository: _repository), + expectation: expectation, + ), + returnsNormally, + ); + } + }); + + test('rejects an unattested package the lockfile has a provenance for', () { + expect( + () => check(verified: null, expectation: _locked), + throwsA( + isA().having( + (e) => e.message, + 'message', + allOf( + contains( + 'helpful 0.1.4 is not attested, but an attestation is ' + 'expected', + ), + contains('pubspec.lock records that helpful 0.1.4 was verified'), + contains('`https://pub.dev` served no attestation'), + contains( + 'remove the `provenance` entry of helpful from ' + 'pubspec.lock', + ), + ), + ), + ), ); + }); + test('rejects an unattested version of a previously attested package', () { expect( - () => ProvenancePolicy.enforcePolicy( - packageName: 'helpful', - version: Version(0, 1, 4), - currentProvenance: null, - previousLockedProvenance: prev, + () => check(verified: null, expectation: _previouslyAttested), + throwsA( + isA().having( + (e) => e.message, + 'message', + allOf( + contains('verified for helpful 0.1.3 on this machine'), + contains('versions from 0.1.3 on are expected to be attested'), + contains('delete `$_markerPath`'), + ), + ), ), - throwsA(isA()), ); }); - test('detects repository switch when configured as fatal', () { - final prev = ProvenanceInfo( - repository: 'https://github.com/mosuem/helpful', - ref: 'refs/tags/v0.1.3', + test('reports a package built from another repository', () { + final messages = []; + check( + verified: ProvenanceInfo(repository: 'https://github.com/evil/helpful'), + expectation: _locked, + onRepositoryChange: messages.add, ); - final current = ProvenanceInfo( - repository: 'https://github.com/attacker/helpful', - ref: 'refs/tags/v0.1.4', + expect(messages, hasLength(1)); + expect( + messages.single, + allOf( + contains('attested as built from https://github.com/evil/helpful'), + contains('pubspec.lock records'), + contains(_repository), + ), ); + }); + test('rejects a package built from another repository when fatal', () { expect( - () => ProvenancePolicy.enforcePolicy( - packageName: 'helpful', - version: Version(0, 1, 4), - currentProvenance: current, - previousLockedProvenance: prev, - fatalOnRepoMismatch: true, + () => check( + verified: ProvenanceInfo(repository: 'https://github.com/evil/helpful'), + expectation: _previouslyAttested, + fatalOnRepositoryChange: true, ), throwsA(isA()), ); }); + + test('compares GitHub repositories by owner and name', () { + expect( + ProvenancePolicy.repositoriesMatch( + 'https://github.com/Mosuem/Helpful.git', + 'https://github.com/mosuem/helpful', + ), + isTrue, + ); + expect( + ProvenancePolicy.repositoriesMatch( + 'https://github.com/evil-mosuem/helpful', + 'https://github.com/mosuem/helpful', + ), + isFalse, + ); + expect( + ProvenancePolicy.repositoriesMatch( + 'https://gitlab.com/mosuem/helpful', + 'https://gitlab.com/mosuem/helpful', + ), + isTrue, + ); + }); }