diff --git a/doc/repository-spec-v2.md b/doc/repository-spec-v2.md index 05f276a4f78..dc97f04e497 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,81 @@ 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` + +**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`. + +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*. + +#### 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 new file mode 100644 index 00000000000..1825aab6144 --- /dev/null +++ b/lib/src/sigstore/lockfile_policy.dart @@ -0,0 +1,195 @@ +// 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'; +import 'package:sigstore/provenance.dart'; + +import '../exceptions.dart'; + +/// Exception thrown when package integrity or provenance policies fail. +class PackageProvenanceException extends DataException { + PackageProvenanceException(super.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); +} + +/// 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 { + /// 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). + /// + /// 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? verified, + required AttestationExpectation? expectation, + required String repositoryUrl, + bool fatalOnRepositoryChange = false, + void Function(String message)? onRepositoryChange, + }) { + if (expectation == null) return; + + if (verified == null) { + throw PackageProvenanceException(''' +Package $package $version is not attested, but an attestation is expected: +${expectation.describe(package, version)} + +The package repository `$repositoryUrl` served no attestation for it. +${expectation.howToDrop(package)} +'''); + } + + 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); + } + } + + /// 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; + return a.trim().toLowerCase() == b.trim().toLowerCase(); + } +} 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..c5c9533f6b5 --- /dev/null +++ b/lib/src/sigstore/sigstore_stub.dart @@ -0,0 +1,55 @@ +// 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`. +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.'); + + 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/trusted_root.dart b/lib/src/sigstore/trusted_root.dart new file mode 100644 index 00000000000..7f8ef73b773 --- /dev/null +++ b/lib/src/sigstore/trusted_root.dart @@ -0,0 +1,77 @@ +// 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 '../log.dart' as log; +import '../platform_info.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 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( + '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); + } + + final cachePath = cache?.sigstoreTrustedRootPath; + if (cachePath != null && fileExists(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; +} + +/// 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) { + throw DataException('Failed to parse Sigstore trusted_root.json: $e'); + } +} diff --git a/lib/src/sigstore/verifier.dart b/lib/src/sigstore/verifier.dart new file mode 100644 index 00000000000..0e1fdf5316b --- /dev/null +++ b/lib/src/sigstore/verifier.dart @@ -0,0 +1,291 @@ +// 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:crypto/crypto.dart'; +import 'package:pub_semver/pub_semver.dart'; +import 'package:sigstore/provenance.dart'; + +import '../system_cache.dart'; +import 'lockfile_policy.dart'; +import 'sigstore.dart'; +import 'trusted_root.dart'; + +export 'package:sigstore/provenance.dart'; + +export 'lockfile_policy.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; + + /// 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.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. +class PubAttestationVerifier { + final SystemCache? _cache; + 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, + // 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, + _overrideTrustedRootJson = overrideTrustedRootJson, + _offline = offline, + _trustedSignerWorkflows = trustedSignerWorkflows, + _requireTaggedSignerRef = requireTaggedSignerRef; + + /// 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. + /// 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* 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 + /// repository proves nothing beyond "somebody's trusted workflow built + /// this". + AttestationVerificationResult verify({ + required String packageName, + required Version packageVersion, + 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 { + // 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( + '', + githubActionsOidcIssuer, + _offline, + false, + trustedRootJson, + '', + ); + + // 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, + packageVersion: packageVersion, + errors: ['Attestation signature verification failed'], + ); + } + + 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: [e.message], + ); + } + + final errors = checkProvenancePolicy( + packageName: packageName, + packageVersion: packageVersion.toString(), + archiveSha256: Digest(archiveSha256).toString(), + provenance: provenance, + signer: signer, + oidcIssuer: issuer, + declaredPackageName: declaredPackageName, + declaredPackageVersion: declaredPackageVersion?.toString(), + 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, + 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) { + // Anything unexpected must fail verification, never pass it. + return AttestationVerificationResult.failure( + packageName: packageName, + packageVersion: packageVersion, + signerIdentity: identity, + oidcIssuer: issuer, + errors: [e.toString()], + ); + } + } +} diff --git a/lib/src/source/hosted.dart b/lib/src/source/hosted.dart index 69867a45e4e..806ab599e30 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'; @@ -257,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.'); @@ -270,6 +275,7 @@ class HostedSource extends CachedSource { ResolvedHostedDescription( HostedDescription(name, url), sha256: _parseContentHash(sha256 as String?), + provenance: provenance as String?, ), ); } @@ -443,12 +449,18 @@ class HostedSource extends CachedSource { isRetracted: retracted, advisoriesUpdated: advisoriesDate, ); + 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, Uri.parse(archiveUrl), status, parsedContentHash, + slsaLevel: slsaLevel, ); }).toList(); } @@ -1308,8 +1320,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) { @@ -1318,13 +1334,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, ); @@ -1380,6 +1400,134 @@ 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 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); + + 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, + 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, 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 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. + String packageProvenancePath( + HostedDescription description, + SystemCache cache, + ) { + return p.join( + cache.rootDir, + 'hosted-provenance', + _urlToDirectory(description.url), + '${description.packageName}.provenance', + ); + } + + /// 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 { + 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 /// system cache from any server. /// @@ -1566,18 +1714,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; @@ -1586,8 +1735,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, @@ -1619,7 +1769,17 @@ class HostedSource extends CachedSource { ); } - late final Uint8List contentHash; + Uint8List? contentHash; + String? attestationJson; + + // Attestations are fetched when the repository says there is one, but also + // 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 || expectation != null; + final archiveUrl = versionInfo.archiveUrl; log.io('Get package from $archiveUrl.'); log.fine('Downloading ${log.bold(id.name)} ${id.version}...'); @@ -1654,8 +1814,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, @@ -1704,15 +1865,169 @@ See $contentHashesDocumentationUrl. _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) { dataError('Failed to extract `$archivePath`: ${e.message}.'); } - return contentHash; + + final verifiedProvenance = _verifyAttestation( + id, + description, + cache, + attestationJson: attestationJson, + archiveSha256: contentHash!, + extractedPath: destPath, + slsaLevel: versionInfo.slsaLevel, + expectation: expectation, + ); + if (verifiedProvenance != null) { + writeProvenance(id, cache, verifiedProvenance); + } + return (contentHash!, verifiedProvenance); }); } + /// 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. + String? _verifyAttestation( + PackageId id, + HostedDescription description, + SystemCache cache, { + required String? attestationJson, + required Uint8List archiveSha256, + required String extractedPath, + required int? slsaLevel, + required AttestationExpectation? expectation, + }) { + 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, + archiveSha256: archiveSha256, + 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 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, + verified: result?.provenance, + expectation: expectation, + repositoryUrl: description.url, + onRepositoryChange: 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); @@ -2018,30 +2333,43 @@ 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. + // 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) && // 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 || @@ -2050,7 +2378,25 @@ class ResolvedHostedDescription extends ResolvedDescription { } ResolvedHostedDescription withSha256(Uint8List? newSha256) => - ResolvedHostedDescription(description, sha256: newSha256); + ResolvedHostedDescription( + description, + sha256: newSha256, + provenance: provenance, + ); + + /// Returns a description with [newProvenance] as its provenance. + /// + /// `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, + ); } /// Information about a package version retrieved from /api/packages/$package< @@ -2062,14 +2408,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/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/pubspec.lock b/pubspec.lock index 65fe6125e90..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: @@ -65,6 +81,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "828110d598123b5ea96c00c9f3c72105bf79f8ee36c20a39b26209ade421ec57" + url: "https://pub.dev" + source: hosted + version: "2.1.0" collection: dependency: "direct main" description: @@ -105,6 +129,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 +169,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 +237,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: @@ -209,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: @@ -233,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: @@ -241,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: @@ -249,6 +321,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: @@ -289,6 +369,15 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + sigstore: + dependency: "direct main" + description: + 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: @@ -466,4 +555,4 @@ packages: source: hosted version: "2.2.2" sdks: - dart: ">=3.9.0 <4.0.0" + dart: ">=3.13.3 <4.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 2009dd08405..bc8ad52fbe5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -24,6 +24,13 @@ dependencies: pool: ^1.0.0 pub_semver: ^2.2.0 shelf: ^1.4.2 + # 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/get/hosted/attestation_verification_test.dart b/test/get/hosted/attestation_verification_test.dart new file mode 100644 index 00000000000..87f955ac3df --- /dev/null +++ b/test/get/hosted/attestation_verification_test.dart @@ -0,0 +1,347 @@ +// 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 '../../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(jsonDecode(packageProvenance.readAsStringSync()), { + 'repository': 'https://github.com/mosuem/helpful', + 'since': '0.1.5', + }); + + final lockfilePath = p.join(d.sandbox, appPath, 'pubspec.lock'); + expect( + File(lockfilePath).readAsStringSync(), + contains('provenance: "https://github.com/mosuem/helpful"'), + ); + + // 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( + Directory(server.pathInCache('helpful', '0.1.4')).existsSync(), + isTrue, + ); + expect( + File(lockfilePath).readAsStringSync(), + isNot(contains('provenance')), + ); + + // 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: 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'); + + await d + .appDir( + dependencies: { + 'foo': {'hosted': server.url, 'version': '^1.0.0'}, + }, + ) + .create(); + + await pubGet(); + 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', slsaLevel: 2); + + server.handle('/api/packages/foo/versions/1.0.0/attestation', (request) { + return Response.ok( + sampleBundleJson, + headers: {'content-type': 'application/json; charset="utf-8"'}, + ); + }); + + await d + .appDir( + dependencies: { + 'foo': {'hosted': server.url, 'version': '^1.0.0'}, + }, + ) + .create(); + + await pubGet( + error: contains('failed Sigstore attestation verification'), + exitCode: TEMP_FAIL, + ); + }, + ); + + 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. + 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 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'); + + 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( + jsonEncode({ + 'repository': 'https://github.com/dieter/helpful', + 'since': '0.9.0', + }), + ); + + await pubGet( + 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/package_server.dart b/test/package_server.dart index c81fca966a3..0670cd78ecb 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) 'slsa_level': version.slsaLevel, if (version.sha256 != null || server.serveContentHashes) 'archive_sha256': version.sha256 ?? @@ -284,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 = {}; @@ -300,8 +304,10 @@ class PackageServer { Map? deps, Map? pubspec, List? contents, + List? archiveBytes, String? sdk, Map>? headers, + int? slsaLevel, }) { final pubspecFields = { 'name': name, @@ -318,7 +324,11 @@ class PackageServer { package.versions[version] = _ServedPackageVersion( pubspecFields, headers: headers, - contents: () => tarFromDescriptors(contents ?? []), + contents: + archiveBytes != null + ? () => Stream.value(archiveBytes) + : () => tarFromDescriptors(contents ?? []), + slsaLevel: slsaLevel, ); } @@ -426,10 +436,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()); diff --git a/test/sigstore/lockfile_policy_test.dart b/test/sigstore/lockfile_policy_test.dart new file mode 100644 index 00000000000..43b42c8b9c7 --- /dev/null +++ b/test/sigstore/lockfile_policy_test.dart @@ -0,0 +1,159 @@ +// 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'; + +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('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( + () => check( + verified: ProvenanceInfo(repository: _repository), + expectation: null, + ), + returnsNormally, + ); + }); + + 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( + () => 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`'), + ), + ), + ), + ); + }); + + test('reports a package built from another repository', () { + final messages = []; + check( + verified: ProvenanceInfo(repository: 'https://github.com/evil/helpful'), + expectation: _locked, + onRepositoryChange: messages.add, + ); + 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( + () => 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, + ); + }); +} diff --git a/test/sigstore/test_fixtures.dart b/test/sigstore/test_fixtures.dart new file mode 100644 index 00000000000..f14e6be1b5e --- /dev/null +++ b/test/sigstore/test_fixtures.dart @@ -0,0 +1,93 @@ +// 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="}}'''; + +/// 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":"MIIHSTCCBs+gAwIBAgIUSbKrv5rrrUU+VoJP3DsVsMBUkaMwCgYIKoZIzj0EAwMwNzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwHhcNMjYwOTAxMTM0MjM3WhcNMjYwOTAxMTM1MjM3WjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEs0YJSdT2XNhV0PKBbDp7UrXyr8C5pi1Cy8bT1W4d2yp76hodCV0+to/Vxaw2FCnJKqdDzdV+CKnbe2bE9WrTjKOCBe4wggXqMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQUoz1Pr/VYOUGAo4DnIMywF5OO9CcwHwYDVR0jBBgwFoAU39Ppz1YkEZb5qNjpKFWixi4YZD8wewYDVR0RAQH/BHEwb4ZtaHR0cHM6Ly9naXRodWIuY29tL2RhcnQtbGFuZy9zZXR1cC1kYXJ0Ly5naXRodWIvd29ya2Zsb3dzL3B1Ymxpc2gueW1sQHJlZnMvaGVhZHMvcHVibGlzaC1wYWNrYWdlLWF0dGVzdGF0aW9uczA5BgorBgEEAYO/MAEBBCtodHRwczovL3Rva2VuLmFjdGlvbnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tMB8GCisGAQQBg78wAQIEEXdvcmtmbG93X2Rpc3BhdGNoMDYGCisGAQQBg78wAQMEKDYzMjE2YTMwMjJlZDYwNjUzODM5OTYzNGNkMWM1MjVmOGJkNDY1N2YwKAYKKwYBBAGDvzABBAQaUHVibGlzaCBoZWxwZnVsIHRvIHB1Yi5kZXYwHAYKKwYBBAGDvzABBQQObW9zdWVtL2hlbHBmdWwwHQYKKwYBBAGDvzABBgQPcmVmcy9oZWFkcy9tYWluMDsGCisGAQQBg78wAQgELQwraHR0cHM6Ly90b2tlbi5hY3Rpb25zLmdpdGh1YnVzZXJjb250ZW50LmNvbTB9BgorBgEEAYO/MAEJBG8MbWh0dHBzOi8vZ2l0aHViLmNvbS9kYXJ0LWxhbmcvc2V0dXAtZGFydC8uZ2l0aHViL3dvcmtmbG93cy9wdWJsaXNoLnltbEByZWZzL2hlYWRzL3B1Ymxpc2gtcGFja2FnZS1hdHRlc3RhdGlvbnMwOAYKKwYBBAGDvzABCgQqDChkZjdmYjdhNmMwYjg5MzEwZTgzMDg2YmQ2MWI3Nzk1N2RmOTYyOWM0MB0GCisGAQQBg78wAQsEDwwNZ2l0aHViLWhvc3RlZDAxBgorBgEEAYO/MAEMBCMMIWh0dHBzOi8vZ2l0aHViLmNvbS9tb3N1ZW0vaGVscGZ1bDA4BgorBgEEAYO/MAENBCoMKDYzMjE2YTMwMjJlZDYwNjUzODM5OTYzNGNkMWM1MjVmOGJkNDY1N2YwHwYKKwYBBAGDvzABDgQRDA9yZWZzL2hlYWRzL21haW4wGgYKKwYBBAGDvzABDwQMDAoxMzM5MzEzMDI1MCkGCisGAQQBg78wARAEGwwZaHR0cHM6Ly9naXRodWIuY29tL21vc3VlbTAYBgorBgEEAYO/MAERBAoMCDI3ODU5MDgyMGAGCisGAQQBg78wARIEUgxQaHR0cHM6Ly9naXRodWIuY29tL21vc3VlbS9oZWxwZnVsLy5naXRodWIvd29ya2Zsb3dzL3B1Ymxpc2gueWFtbEByZWZzL2hlYWRzL21haW4wOAYKKwYBBAGDvzABEwQqDCg2MzIxNmEzMDIyZWQ2MDY1MzgzOTk2MzRjZDFjNTI1ZjhiZDQ2NTdmMCEGCisGAQQBg78wARQEEwwRd29ya2Zsb3dfZGlzcGF0Y2gwVQYKKwYBBAGDvzABFQRHDEVodHRwczovL2dpdGh1Yi5jb20vbW9zdWVtL2hlbHBmdWwvYWN0aW9ucy9ydW5zLzMzNTE0OTQzNTU1L2F0dGVtcHRzLzEwFgYKKwYBBAGDvzABFgQIDAZwdWJsaWMwSwYKKwYBBAGDvzABGAQ9DDtyZXBvOm1vc3VlbUAyNzg1OTA4Mi9oZWxwZnVsQDEzMzkzMTMwMjU6cmVmOnJlZnMvaGVhZHMvbWFpbjCBiwYKKwYBBAHWeQIEAgR9BHsAeQB3AN09MGrGxxEyYxkeHJlnNwKiSl643jyt/4eKcoAvKe6OAAABoF01Ho4AAAQDAEgwRgIhAKltr5fOLXg8V5sxC0sbJoDfvglVl56Rotrn8uPWbmqmAiEA52CQuybPHvQcU0h+l4+Zba+eHc9kPIdm70U5UntXUzwwCgYIKoZIzj0EAwMDaAAwZQIwNpYvkFydkCrG57qLtB/yeOaGVssMOjQiLQxSJyHh9m/t/LTUdqzgYOKjyGBEgLhmAjEA2dicfThdV1SjGnX7Z95UKI1IPMQfOO232Btqr7Aq2lWlNgp2xCElkYa/oBdMkcDB"},"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/trusted_root_test.dart b/test/sigstore/trusted_root_test.dart new file mode 100644 index 00000000000..26a3719ba68 --- /dev/null +++ b/test/sigstore/trusted_root_test.dart @@ -0,0 +1,84 @@ +// 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:pub/src/system_cache.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()), + ); + }); + + 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('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 new file mode 100644 index 00000000000..21cc36daf5b --- /dev/null +++ b/test/sigstore/verifier_test.dart @@ -0,0 +1,148 @@ +// 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: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'; + +import 'test_fixtures.dart'; + +void main() { + 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), + archiveSha256: sha256.convert(helpfulArchiveBytes).bytes, + 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), + archiveSha256: sha256.convert(helpfulArchiveBytes).bytes, + 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), + archiveSha256: sha256.convert(helpfulArchiveBytes).bytes, + 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 result = verifier.verify( + packageName: 'sample', + packageVersion: Version(1, 0, 0), + archiveSha256: sha256.convert(sampleArtifactBytes).bytes, + bundleJson: sampleBundleJson, + expectedRepository: + '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 tamperedBytes = Uint8List.fromList([1, 2, 3, 4, 5]); + + final result = verifier.verify( + packageName: 'sample', + packageVersion: Version(1, 0, 0), + archiveSha256: sha256.convert(tamperedBytes).bytes, + bundleJson: sampleBundleJson, + ); + + expect(result.isValid, isFalse); + expect(result.errors, isNotEmpty); + }); + + test('fails when the attestation is not a Sigstore bundle', () { + final verifier = PubAttestationVerifier(); + + final result = verifier.verify( + packageName: 'sample', + packageVersion: Version(1, 0, 0), + archiveSha256: sha256.convert(sampleArtifactBytes).bytes, + bundleJson: '{"not": "a bundle"}', + ); + + expect(result.isValid, isFalse); + expect(result.errors, isNotEmpty); + }); + + 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 result = verifier.verify( + packageName: 'helpful', + packageVersion: Version(1, 0, 0), + archiveSha256: sha256.convert(sampleArtifactBytes).bytes, + bundleJson: buildProvenanceBundleJson( + subjectName: 'helpful-1.0.0.tar.gz', + ), + expectedRepository: 'https://github.com/dieter/helpful', + ); + + expect(result.isValid, isFalse); + }); +}