From ff2b1ff5692e09ea54d46336e1e6f9e8b951e9ec Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 12 Oct 2026 12:50:14 +0000 Subject: [PATCH 1/3] Verify uploaded package attestations using package:sigstore Adds `package:sigstore` and an `AttestationVerifier` service that verifies the Sigstore bundle uploaded with a package archive before the version is published: - verifies the Fulcio certificate signer identity against the trusted reusable publishing workflows (`defaultTrustedSignerWorkflows`), over the archive's sha256 digest that `publishUploadedBlob` already computed (`SigstoreClient.verify(..., isDigest: true)`), so the archive is neither read into memory nor hashed again, - parses the in-toto SLSA v1 DSSE provenance statement from the same bundle JSON (`BuildProvenance.parseBundle`), - enforces the provenance policy (`checkProvenancePolicy`): binds the archive sha256 and subject name, verifies `provenance.sourceRepository` against the `repository:` of the uploaded `pubspec.yaml`, and - when GitHub Actions automated publishing is enabled for the package - also against `publishingConfig.githubConfig.repository`, - rejects the upload with `PackageRejectedException` when the attestation is invalid or fails verification; internal errors (e.g. the native library missing) propagate as a 500 instead of blaming the upload. `AttestationVerifier` is a registered service (`registerAttestationVerifier` / `attestationVerifier`); `withFakeServices` registers a `FakeAttestationVerifier` that replaces only the cryptographic step and trusts the builder self-reported in the statement, so tests exercise the real provenance parsing and policy with hand-crafted bundles. An end-to-end test verifies a real signed bundle. Deployment: `package:sigstore` calls into `libsigstore_ffi.so` through `@Native` bindings, which the VM resolves through a native assets mapping that `dart compile jit-snapshot` does not produce. `tool/build-server- snapshot.sh` compiles the server to kernel, compiles the mapping (asset id -> path of the library in the image) to a kernel component with the frontend server, concatenates the two and creates the app-JIT snapshot from the result; the VM keeps the mapping in the snapshot, so no hooks or package sources are needed at runtime (the mechanism `dart run` uses, cf. dart-lang/pub#4888). `Dockerfile.app` builds the library from the Rust source bundled with the package in a `rust-build` stage (no prebuilt binaries are downloaded) and copies it, with `libgcc_s`, into the final image. `server.jit self-check` calls into the native library; `dockerfile_app_build_test.dart` runs it in the built image, and the frontend entrypoint performs the same check at startup so that a broken image fails to boot instead of failing every attested upload. --- Dockerfile.app | 36 +- app/bin/server.dart | 4 +- .../backend/fake_attestation_verifier.dart | 46 ++ app/lib/package/attestation_verifier.dart | 725 ++++++++++++++++++ app/lib/package/backend.dart | 33 + app/lib/service/entrypoint/frontend.dart | 6 + app/lib/service/entrypoint/self_check.dart | 29 + app/lib/service/services.dart | 4 + app/pubspec.yaml | 1 + app/test/package/upload_test.dart | 393 +++++++++- .../entrypoint/dockerfile_app_build_test.dart | 10 +- pkg/_pub_shared/lib/worker/docker_utils.dart | 15 + pubspec.lock | 32 + tool/build-server-snapshot.sh | 94 +++ 14 files changed, 1409 insertions(+), 19 deletions(-) create mode 100644 app/lib/fake/backend/fake_attestation_verifier.dart create mode 100644 app/lib/package/attestation_verifier.dart create mode 100644 app/lib/service/entrypoint/self_check.dart create mode 100755 tool/build-server-snapshot.sh diff --git a/Dockerfile.app b/Dockerfile.app index 9a82ead4bd..a5b810c7ae 100644 --- a/Dockerfile.app +++ b/Dockerfile.app @@ -41,8 +41,26 @@ RUN dart /project/tool/pub_get_offline.dart /project/app RUN /project/tool/download-sdk-index-jsons.sh -# Generate JIT snapshot. We run it with --help to make it exit. -RUN dart compile jit-snapshot -o server.jit bin/server.dart --help +# Generate the JIT snapshot, with the native assets mapping of package:sigstore +# embedded (see tool/build-server-snapshot.sh). The library is built in the +# rust-build stage below and copied to the given path in the final image. +RUN /project/tool/build-server-snapshot.sh /project/app/server.jit /project/app/libsigstore_ffi.so + +# Collect what the rust-build and final stages need from this one: +# - the Rust source of package:sigstore's native library, as resolved by pub, +# - libgcc_s, which the Rust library depends on and which is not part of the +# minimal /runtime of the Dart image. +RUN set -eux; \ + SIGSTORE_DIRS="$(ls -d /project/.pub-cache/hosted/pub.dev/sigstore-*/)"; \ + [ "$(echo "$SIGSTORE_DIRS" | wc -l)" -eq 1 ]; \ + cp -r "${SIGSTORE_DIRS}rust" /project/sigstore-rust; \ + case "$(dpkg --print-architecture)" in \ + amd64) TRIPLET="x86_64-linux-gnu" ;; \ + arm64) TRIPLET="aarch64-linux-gnu" ;; \ + *) echo "Unsupported architecture"; exit 1 ;; \ + esac; \ + mkdir -p "/project/runtime-extra/lib/$TRIPLET"; \ + cp --dereference "/lib/$TRIPLET/libgcc_s.so.1" "/project/runtime-extra/lib/$TRIPLET/libgcc_s.so.1" # Create /tmp directory for the runtime. RUN mkdir -p /project/tmp && chmod 1777 /project/tmp @@ -51,9 +69,21 @@ RUN mkdir -p /project/tmp && chmod 1777 /project/tmp RUN dart compile kernel -o search_index.dill lib/service/entrypoint/search_index.dart RUN dart compile kernel -o sdk_isolate_index.dill lib/service/entrypoint/sdk_isolate_index.dart +# Stage: Build the native library of package:sigstore from the Rust source +# bundled with the package (no prebuilt binaries are downloaded). +FROM mirror.gcr.io/library/rust:1.99-bookworm AS rust-build +RUN apt-get update && \ + apt-get install -y --no-install-recommends cmake && \ + rm -rf /var/lib/apt/lists/* +COPY --from=build /project/sigstore-rust /project/sigstore-rust +WORKDIR /project/sigstore-rust +RUN cargo rustc --release --locked --crate-type=cdylib && \ + test -f target/release/libsigstore_ffi.so + # Build minimal serving image. FROM scratch COPY --from=build /runtime/ / +COPY --from=build /project/runtime-extra/ / COPY --from=build /project/tmp /tmp COPY --from=build /usr/lib/dart /usr/lib/dart COPY --from=build /project/app/server.jit /project/app/ @@ -66,6 +96,8 @@ COPY --from=build /project/app/lib/frontend/templates /project/app/lib/frontend/ COPY --from=build /project/app/.dart_tool/pub-search-data /project/app/.dart_tool/pub-search-data COPY --from=build /project/.dart_tool/package_config.json /project/.dart_tool/package_config.json COPY --from=go-build /project/pkg/signature_verifier/signature_verifier /project/app/signature_verifier +# Must match the path given to tool/build-server-snapshot.sh above. +COPY --from=rust-build /project/sigstore-rust/target/release/libsigstore_ffi.so /project/app/libsigstore_ffi.so # Put the kernel snapshots at the same place as the source files for Isolate.spawnUri to work transparently. COPY --from=build /project/app/search_index.dill /project/app/search_index.dill COPY --from=build /project/app/sdk_isolate_index.dill /project/app/sdk_isolate_index.dill diff --git a/app/bin/server.dart b/app/bin/server.dart index ba9a1b1ffb..2628813e0c 100644 --- a/app/bin/server.dart +++ b/app/bin/server.dart @@ -7,13 +7,15 @@ import 'package:args/command_runner.dart'; import 'package:pub_dev/service/entrypoint/analyzer.dart'; import 'package:pub_dev/service/entrypoint/frontend.dart'; import 'package:pub_dev/service/entrypoint/search.dart'; +import 'package:pub_dev/service/entrypoint/self_check.dart'; import 'package:pub_dev/shared/env_config.dart'; void main(List args) async { final runner = CommandRunner('pub_dev', 'pub.dev services') ..addCommand(AnalyzerCommand()) ..addCommand(DefaultCommand()) - ..addCommand(SearchCommand()); + ..addCommand(SearchCommand()) + ..addCommand(SelfCheckCommand()); if (args.isEmpty && envConfig.isRunningInCloud && envConfig.service != null) { args = [envConfig.service!]; diff --git a/app/lib/fake/backend/fake_attestation_verifier.dart b/app/lib/fake/backend/fake_attestation_verifier.dart new file mode 100644 index 0000000000..1c84b2cda6 --- /dev/null +++ b/app/lib/fake/backend/fake_attestation_verifier.dart @@ -0,0 +1,46 @@ +// 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/attestation_verifier.dart'; + +/// An [AttestationVerifier] that does not verify Sigstore signatures. +/// +/// Only the cryptographic step is replaced: the signer is taken from the +/// builder self-reported in the provenance statement (or defaults to the +/// `dart-lang/setup-dart` publishing workflow), so tests can use hand-crafted +/// bundles without a valid signature. Provenance parsing and the policy checks +/// are the real ones. +class FakeAttestationVerifier extends AttestationVerifier { + static final _defaultSigner = GitHubWorkflowIdentity.tryParse( + 'https://github.com/dart-lang/setup-dart/' + '.github/workflows/publish.yml@refs/tags/v2', + )!; + + FakeAttestationVerifier({ + super.trustedSignerWorkflows, + super.requireTaggedSignerRef, + }); + + /// The fake does not use the native library. + @override + void checkNativeLibrary() {} + + @override + VerifiedSigner verifySignature({ + required List archiveSha256, + required String bundleJson, + }) { + GitHubWorkflowIdentity? builder; + try { + builder = BuildProvenance.parseBundle(bundleJson).builder; + } on FormatException { + // Not a provenance bundle: [AttestationVerifier.verify] parses it again + // and reports the problem, the signer is irrelevant at that point. + } + return VerifiedSigner( + identity: (builder ?? _defaultSigner).toString(), + issuer: githubActionsOidcIssuer, + ); + } +} diff --git a/app/lib/package/attestation_verifier.dart b/app/lib/package/attestation_verifier.dart new file mode 100644 index 0000000000..4ac2c7baf8 --- /dev/null +++ b/app/lib/package/attestation_verifier.dart @@ -0,0 +1,725 @@ +// 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 'package:collection/collection.dart'; +import 'package:crypto/crypto.dart'; +import 'package:gcloud/service_scope.dart' as ss; +import 'package:meta/meta.dart'; +import 'package:pub_semver/pub_semver.dart'; +import 'package:sigstore/sigstore.dart'; + +export 'package:sigstore/sigstore.dart'; + +/// The OIDC issuer of tokens minted by GitHub Actions. +const githubActionsOidcIssuer = 'https://token.actions.githubusercontent.com'; + +const _inTotoStatementType = 'https://in-toto.io/Statement/v1'; +const _inTotoPayloadType = 'application/vnd.in-toto+json'; +const _slsaProvenancePredicateType = 'https://slsa.dev/provenance/v1'; + +/// 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', +}; + +/// A GitHub repository, identified by its [owner] and [name]. +/// +/// GitHub treats owner and repository names case-insensitively, and so does +/// [operator ==]. +class GitHubRepository { + final String owner; + final String name; + + GitHubRepository(this.owner, this.name); + + /// Parses a reference to a GitHub repository URL, or returns `null` if + /// [source] does not denote one. + /// + /// Accepts the forms used in `pubspec.yaml`'s `repository:` field and in + /// SLSA provenance statements: + /// + /// * `https://github.com//` + /// * `https://github.com//.git` + /// * `git+https://github.com//@` + /// * `https://github.com///tree//` (monorepos) + static GitHubRepository? tryParse(String source) { + var s = source.trim(); + if (s.isEmpty) return null; + if (s.startsWith('git+')) { + s = s.substring('git+'.length); + final at = s.lastIndexOf('@'); + if (at > 0) s = s.substring(0, at); + } + final uri = Uri.tryParse(s); + if (uri == null) return null; + if (uri.scheme != 'http' && uri.scheme != 'https') return null; + final host = uri.host.toLowerCase(); + if (host != 'github.com' && host != 'www.github.com') return null; + final segments = uri.pathSegments.where((s) => s.isNotEmpty).toList(); + if (segments.length < 2) return null; + final owner = segments[0]; + var name = segments[1]; + if (name.endsWith('.git')) { + name = name.substring(0, name.length - '.git'.length); + } + if (owner.isEmpty || name.isEmpty) return null; + return GitHubRepository(owner, name); + } + + /// Parses an `/` slug (as stored in + /// `GitHubPublishingConfig.repository`) or a GitHub repository URL. + static GitHubRepository? tryParseSlug(String source) { + final fromUrl = tryParse(source); + if (fromUrl != null) return fromUrl; + final trimmed = source.trim(); + if (trimmed.isEmpty || trimmed.contains(':')) return null; + final parts = trimmed.split('/'); + if (parts.length != 2) return null; + final owner = parts[0]; + var name = parts[1]; + if (name.endsWith('.git')) { + name = name.substring(0, name.length - '.git'.length); + } + if (owner.isEmpty || name.isEmpty) return null; + return GitHubRepository(owner, name); + } + + /// `/`, as used by the GitHub API and in OIDC claims. + String get slug => '$owner/$name'; + + /// The canonical url of this repository. + String get url => 'https://github.com/$owner/$name'; + + @override + bool operator ==(Object other) => + other is GitHubRepository && + owner.toLowerCase() == other.owner.toLowerCase() && + name.toLowerCase() == other.name.toLowerCase(); + + @override + int get hashCode => Object.hash(owner.toLowerCase(), name.toLowerCase()); + + @override + String toString() => url; +} + +/// The identity of the workflow that signed an attestation. +/// +/// This is the `job_workflow_ref` claim of the GitHub Actions OIDC token, +/// which Fulcio puts in the subject alternative name of the signing +/// certificate. For a reusable workflow it identifies the *reusable* workflow +/// - the trusted builder - and not the repository that called it. +class GitHubWorkflowIdentity { + final GitHubRepository repository; + + /// The path of the workflow file within [repository], e.g. + /// `.github/workflows/publish.yml`. + final String path; + + /// The git ref the workflow ran at, e.g. `refs/tags/v2`. + final String ref; + + GitHubWorkflowIdentity({ + required this.repository, + required this.path, + required this.ref, + }); + + /// Parses an identity of the form + /// `https://github.com///@`. + static GitHubWorkflowIdentity? tryParse(String identity) { + final at = identity.lastIndexOf('@'); + if (at <= 0) return null; + final url = identity.substring(0, at); + final ref = identity.substring(at + 1); + if (ref.isEmpty) return null; + final repository = GitHubRepository.tryParse(url); + if (repository == null) return null; + final uri = Uri.tryParse(url); + if (uri == null) return null; + final segments = uri.pathSegments.where((s) => s.isNotEmpty).toList(); + if (segments.length < 3) return null; + return GitHubWorkflowIdentity( + repository: repository, + path: segments.sublist(2).join('/'), + ref: ref, + ); + } + + /// `//` - the workflow without the ref it ran at. + String get workflow => '${repository.slug}/$path'; + + @override + String toString() => '${repository.url}/$path@$ref'; +} + +/// An entry of the `subject` list of an in-toto statement. +class ProvenanceSubject { + final String name; + + /// The lowercase hex-encoded sha256 digest, or `null` if the subject is not + /// identified by a sha256. + final String? sha256; + + ProvenanceSubject(this.name, this.sha256); +} + +/// The SLSA build provenance of an artifact, as carried in the DSSE envelope +/// of a Sigstore bundle. +class BuildProvenance { + /// The repository the build ran for. + /// + /// This is `github.repository` of the workflow run - for a reusable + /// workflow, the repository that *called* the trusted builder. It is filled + /// in by GitHub from the run context and cannot be set by the caller. + final GitHubRepository sourceRepository; + + /// The ref of [sourceRepository] the build ran at, e.g. `refs/tags/v1.0.0`. + final String? sourceRef; + + /// The path of the caller's workflow file. + final String? workflowPath; + + /// The commit the build ran at, if recorded. + final String? commitSha; + + /// The builder as self-reported in the statement. + /// + /// Note that the authoritative statement about who signed is the certificate + /// identity, not this field. + final GitHubWorkflowIdentity? builder; + + final List subjects; + + BuildProvenance({ + required this.sourceRepository, + required this.sourceRef, + required this.workflowPath, + required this.commitSha, + required this.builder, + required this.subjects, + }); + + /// Extracts the build provenance from the DSSE envelope of the Sigstore + /// bundle in [bundleJson]. + /// + /// Throws a [FormatException] if [bundleJson] is not a bundle carrying a + /// SLSA build provenance statement. + /// + /// This does not validate any signature. Only use the result if + /// `package:sigstore` has verified the very same [bundleJson]. + factory BuildProvenance.parseBundle(String bundleJson) { + final Object? decoded; + try { + decoded = json.decode(bundleJson); + } on FormatException catch (e) { + throw FormatException('Attestation is not valid JSON: ${e.message}'); + } + final bundle = _expectMap(decoded, 'bundle'); + final envelope = bundle['dsseEnvelope']; + if (envelope == null) { + throw const FormatException( + 'Attestation does not contain a `dsseEnvelope`, so it carries no ' + 'build provenance.', + ); + } + final dsse = _expectMap(envelope, 'dsseEnvelope'); + final payloadType = dsse['payloadType']; + if (payloadType != _inTotoPayloadType) { + throw FormatException( + 'Attestation payload has type `$payloadType`, ' + 'expected `$_inTotoPayloadType`.', + ); + } + final encodedPayload = dsse['payload']; + if (encodedPayload is! String) { + throw const FormatException('Attestation has no `payload`.'); + } + final Object? decodedStatement; + try { + decodedStatement = json.decode( + utf8.decode(base64.decode(encodedPayload)), + ); + } on FormatException catch (e) { + throw FormatException( + 'Attestation payload is not valid base64-encoded JSON: ${e.message}', + ); + } + final statement = _expectMap(decodedStatement, 'statement'); + + final type = statement['_type']; + if (type != _inTotoStatementType) { + throw FormatException( + 'Attestation statement has type `$type`, ' + 'expected `$_inTotoStatementType`.', + ); + } + final predicateType = statement['predicateType']; + if (predicateType != _slsaProvenancePredicateType) { + throw FormatException( + 'Attestation has predicate type `$predicateType`, ' + 'expected `$_slsaProvenancePredicateType`.', + ); + } + + final subjects = []; + final subjectList = statement['subject']; + if (subjectList is! List || subjectList.isEmpty) { + throw const FormatException('Attestation statement has no `subject`.'); + } + for (final subject in subjectList) { + final s = _expectMap(subject, 'subject'); + final name = s['name']; + if (name is! String) { + throw const FormatException('Attestation subject has no `name`.'); + } + final digest = s['digest']; + final sha256 = digest is Map ? digest['sha256'] : null; + subjects.add( + ProvenanceSubject(name, sha256 is String ? sha256.toLowerCase() : null), + ); + } + + final predicate = _expectMap(statement['predicate'], 'predicate'); + final buildDefinition = _expectMap( + predicate['buildDefinition'], + 'buildDefinition', + ); + final externalParameters = _expectMap( + buildDefinition['externalParameters'], + 'externalParameters', + ); + final workflow = _expectMap(externalParameters['workflow'], 'workflow'); + + final repositoryField = workflow['repository']; + if (repositoryField is! String) { + throw const FormatException( + 'Attestation does not state the repository it was built from.', + ); + } + final sourceRepository = GitHubRepository.tryParse(repositoryField); + if (sourceRepository == null) { + throw FormatException( + 'Attestation was built from `$repositoryField`, which is not a GitHub ' + 'repository. Only GitHub is supported for now.', + ); + } + + String? commitSha; + final resolvedDependencies = buildDefinition['resolvedDependencies']; + if (resolvedDependencies is List) { + for (final dependency in resolvedDependencies) { + if (dependency is! Map) continue; + final uri = dependency['uri']; + if (uri is! String) continue; + if (GitHubRepository.tryParse(uri) != sourceRepository) continue; + final digest = dependency['digest']; + final gitCommit = digest is Map ? digest['gitCommit'] : null; + if (gitCommit is String) { + commitSha = gitCommit; + break; + } + } + } + + GitHubWorkflowIdentity? builder; + final runDetails = predicate['runDetails']; + if (runDetails is Map) { + final builderField = runDetails['builder']; + final id = builderField is Map ? builderField['id'] : null; + if (id is String) builder = GitHubWorkflowIdentity.tryParse(id); + } + + final ref = workflow['ref']; + final path = workflow['path']; + return BuildProvenance( + sourceRepository: sourceRepository, + sourceRef: ref is String ? ref : null, + workflowPath: path is String ? path : null, + commitSha: commitSha, + builder: builder, + subjects: subjects, + ); + } +} + +Map _expectMap(Object? value, String what) { + if (value is! Map) { + throw FormatException('Attestation `$what` is not a JSON object.'); + } + return value; +} + +/// Result of verifying a package attestation bundle. +class AttestationVerificationResult { + final bool isValid; + final String? repository; + final BuildProvenance? provenance; + final String? signerIdentity; + final String? oidcIssuer; + final List errors; + + AttestationVerificationResult({ + required this.isValid, + this.repository, + this.provenance, + this.signerIdentity, + this.oidcIssuer, + this.errors = const [], + }); + + AttestationVerificationResult.failure({ + this.signerIdentity, + this.oidcIssuer, + required this.errors, + }) : isValid = false, + repository = null, + provenance = null; +} + +/// The identity of the signer of an attestation, as verified by Sigstore. +class VerifiedSigner { + /// The subject alternative name of the signing certificate - for GitHub + /// Actions the `job_workflow_ref` of the signing workflow. + final String identity; + + /// The OIDC issuer that authenticated the signer. + final String issuer; + + VerifiedSigner({required this.identity, required this.issuer}); +} + +/// Sets the attestation verifier service. +void registerAttestationVerifier(AttestationVerifier value) => + ss.register(#_attestationVerifier, value); + +/// The active attestation verifier service. +AttestationVerifier get attestationVerifier => + ss.lookup(#_attestationVerifier) as AttestationVerifier; + +/// Verifies package attestation bundles using Sigstore and SLSA build +/// provenance policy. +class AttestationVerifier { + final String? _trustedRootJson; + final bool _offline; + final Set _trustedSignerWorkflows; + final bool _requireTaggedSignerRef; + + AttestationVerifier({ + String? trustedRootJson, + bool offline = true, + Set trustedSignerWorkflows = defaultTrustedSignerWorkflows, + bool requireTaggedSignerRef = false, + }) : _trustedRootJson = trustedRootJson, + _offline = offline, + _trustedSignerWorkflows = trustedSignerWorkflows, + _requireTaggedSignerRef = requireTaggedSignerRef; + + /// Checks that the native library of `package:sigstore` can be loaded and + /// called. + /// + /// Throws (an [ArgumentError] from `dart:ffi`) when the `@Native` bindings + /// cannot be resolved - e.g. when the native assets mapping is missing from + /// the snapshot the server runs from, see `tool/build-server-snapshot.sh`. + /// Meant to be called at startup, so that a broken deployment fails loud + /// instead of failing every attested upload. + void checkNativeLibrary() { + SigstoreClient.create(); + } + + /// Verifies that the archive with the sha256 digest [archiveSha256] is the + /// archive of [packageName] at [packageVersion], built by a trusted workflow + /// from [pubspecRepository] (and [configuredGitHubRepository] when automated + /// publishing is enabled). + /// + /// [bundleJson] is passed as a raw JSON string so that signature verification + /// and DSSE provenance parsing operate on the exact same bytes. + /// + /// Returns a failed [AttestationVerificationResult] when the bundle or the + /// attestation it carries is not acceptable. Throws when the verification + /// itself cannot be carried out (e.g. [SigstoreError.internalError] or a + /// missing native library): such failures are not the uploader's fault and + /// must not be reported as one. + AttestationVerificationResult verify({ + required String packageName, + required Version packageVersion, + required List archiveSha256, + required String bundleJson, + String? pubspecRepository, + String? configuredGitHubRepository, + bool requireRepository = true, + }) { + if (archiveSha256.length != 32) { + throw ArgumentError.value( + archiveSha256, + 'archiveSha256', + 'must be a 32-byte sha256 digest', + ); + } + + // Who signed? Cryptographically verify the bundle over the archive digest. + final VerifiedSigner verified; + try { + verified = verifySignature( + archiveSha256: archiveSha256, + bundleJson: bundleJson, + ); + } on SigstoreError catch (e) { + switch (e) { + case SigstoreError.invalidBundle: + return AttestationVerificationResult.failure( + errors: ['Attestation is not a valid Sigstore bundle.'], + ); + case SigstoreError.verificationFailed: + return AttestationVerificationResult.failure( + errors: ['Attestation signature verification failed.'], + ); + case SigstoreError.internalError: + rethrow; + } + } + final identity = verified.identity; + final issuer = verified.issuer; + + final signer = GitHubWorkflowIdentity.tryParse(identity); + if (signer == null) { + return AttestationVerificationResult.failure( + signerIdentity: identity, + oidcIssuer: issuer, + errors: [ + 'Package attestation verification is currently only supported ' + 'for GitHub Actions workflows (signer identity: "$identity").', + ], + ); + } + + // What does the signed statement say? + final BuildProvenance provenance; + try { + provenance = BuildProvenance.parseBundle(bundleJson); + } on FormatException catch (e) { + return AttestationVerificationResult.failure( + signerIdentity: identity, + oidcIssuer: issuer, + errors: [e.message], + ); + } + + final errors = checkProvenancePolicy( + packageName: packageName, + packageVersion: packageVersion, + archiveSha256: Digest(archiveSha256).toString(), + provenance: provenance, + signer: signer, + oidcIssuer: issuer, + pubspecRepository: pubspecRepository, + configuredGitHubRepository: configuredGitHubRepository, + requireRepository: requireRepository, + trustedSignerWorkflows: _trustedSignerWorkflows, + requireTaggedSignerRef: _requireTaggedSignerRef, + ); + if (errors.isNotEmpty) { + return AttestationVerificationResult.failure( + signerIdentity: identity, + oidcIssuer: issuer, + errors: errors, + ); + } + + return AttestationVerificationResult( + isValid: true, + repository: provenance.sourceRepository.url, + provenance: provenance, + signerIdentity: identity, + oidcIssuer: issuer, + ); + } + + /// Verifies the Sigstore signature of [bundleJson] over the artifact with + /// the sha256 digest [archiveSha256], and returns the verified signer. + /// + /// Throws [SigstoreError.invalidBundle] if [bundleJson] is not a Sigstore + /// bundle, [SigstoreError.verificationFailed] if the signature, certificate + /// chain or transparency log entry do not verify, and other errors when the + /// verification cannot be carried out. + /// + /// Only the cryptographic part lives here, so that tests can replace it + /// (see `FakeAttestationVerifier`) while exercising the real provenance + /// parsing and policy. + @visibleForOverriding + VerifiedSigner verifySignature({ + required List archiveSha256, + required String bundleJson, + }) { + final bundle = SigstoreBundle.fromJson(bundleJson); + final client = SigstoreClient.create(); + // The expected identity is left unrestricted here and enforced by + // [checkProvenancePolicy] against the trusted signer workflows: we accept + // a set of workflows, and the ref they ran at varies. + final policy = SigstoreVerificationPolicy.create( + '', + githubActionsOidcIssuer, + _offline, + false, + _trustedRootJson ?? '', + '', + ); + // Verifying against the digest (`isDigest: true`) avoids reading and + // hashing the archive again; the subject digest of the in-toto statement + // is compared against it. + final result = client.verify(archiveSha256, true, bundle, policy); + if (!result.isValid()) { + // `package:sigstore` reports failures by throwing this enum, keep the + // defensive check consistent with it. + // ignore: only_throw_errors + throw SigstoreError.verificationFailed; + } + return VerifiedSigner( + identity: result.verifiedIdentity(), + issuer: result.verifiedIssuer(), + ); + } +} + +/// Checks a cryptographically verified attestation against pub.dev's policy, +/// and returns the list of violations - empty if the attestation is acceptable. +List checkProvenancePolicy({ + required String packageName, + required Version packageVersion, + required String archiveSha256, + required BuildProvenance provenance, + required GitHubWorkflowIdentity signer, + String? oidcIssuer, + String? pubspecRepository, + String? configuredGitHubRepository, + bool requireRepository = true, + Set trustedSignerWorkflows = defaultTrustedSignerWorkflows, + bool requireTaggedSignerRef = false, +}) { + final errors = []; + + if (oidcIssuer != null && oidcIssuer != githubActionsOidcIssuer) { + errors.add( + 'Attestation was issued by "$oidcIssuer", ' + 'expected "$githubActionsOidcIssuer".', + ); + } + + // Who signed? Only a small set of workflows may sign packages for pub.dev. + final trusted = trustedSignerWorkflows.map((w) => w.toLowerCase()).toSet(); + if (!trusted.contains(signer.workflow.toLowerCase())) { + errors.add( + 'Attestation was signed by "${signer.workflow}", which is not a ' + 'workflow trusted to publish packages. Trusted workflows: ' + '${(trustedSignerWorkflows.toList()..sort()).join(', ')}.', + ); + } else if (requireTaggedSignerRef && !signer.ref.startsWith('refs/tags/')) { + errors.add( + 'Attestation was signed by "${signer.workflow}" at "${signer.ref}", ' + 'but only tagged releases of the publishing workflow are trusted.', + ); + } else if (!signer.ref.startsWith('refs/tags/') && + !signer.ref.startsWith('refs/heads/')) { + errors.add( + 'Attestation was signed by "${signer.workflow}" at "${signer.ref}", ' + 'which is not a branch or tag ref.', + ); + } + final builder = provenance.builder; + if (builder != null && + builder.workflow.toLowerCase() != signer.workflow.toLowerCase()) { + errors.add( + 'Attestation states it was built by "${builder.workflow}" but was ' + 'signed by "${signer.workflow}".', + ); + } + + // What was signed? + // + // The subject name does not bind the package name or version: the + // `dart-lang/setup-dart` publishing workflow signs the archive as + // `package.tar.gz`. The binding is the digest - [packageName] and + // [packageVersion] were read from the `pubspec.yaml` inside the archive + // whose sha256 is [archiveSha256], so matching the digest ties the statement + // to exactly that package version. + const genericSubject = 'package.tar.gz'; + final expectedSubject = '$packageName-$packageVersion.tar.gz'; + final subject = provenance.subjects + .where((s) => s.name == expectedSubject || s.name == genericSubject) + .firstOrNull; + if (subject == null) { + errors.add( + 'Attestation is for ' + '${provenance.subjects.map((s) => '"${s.name}"').join(', ')}, ' + 'not for "$expectedSubject" or "$genericSubject".', + ); + } else if (subject.sha256 != archiveSha256.toLowerCase()) { + errors.add( + 'Attestation is for an archive with sha256 "${subject.sha256}", ' + 'but the uploaded archive has sha256 "$archiveSha256".', + ); + } + + // Where was it built? Verify against the pubspec.yaml `repository` inside the + // uploaded tarball. + if (pubspecRepository == null || pubspecRepository.trim().isEmpty) { + if (requireRepository) { + errors.add( + 'The pubspec.yaml of $packageName $packageVersion does not have a ' + '`repository` field, so the attestation cannot be bound to the ' + 'repository it claims to come from ' + '("${provenance.sourceRepository.url}").', + ); + } + } else { + final expected = GitHubRepository.tryParse(pubspecRepository); + if (expected == null) { + errors.add( + 'Package attestation verification is currently only supported for ' + 'GitHub repositories, but the pubspec.yaml of $packageName ' + '$packageVersion declares `repository: $pubspecRepository`.', + ); + } else if (expected != provenance.sourceRepository) { + errors.add( + 'Attestation states the package was built from ' + '"${provenance.sourceRepository.url}", but the pubspec.yaml of ' + '$packageName $packageVersion declares `repository: ' + '$pubspecRepository`.', + ); + } + } + + // When GitHub Actions automated publishing is configured on pub.dev for this + // package, also verify that the provenance source repository matches the + // stored automated publishing repository configuration (and not just the + // self-asserted `repository:` field inside the uploaded tarball's + // pubspec.yaml). + if (configuredGitHubRepository != null && + configuredGitHubRepository.trim().isNotEmpty) { + final configuredRepo = GitHubRepository.tryParseSlug( + configuredGitHubRepository, + ); + if (configuredRepo == null || + configuredRepo != provenance.sourceRepository) { + errors.add( + 'Attestation states the package was built from ' + '"${provenance.sourceRepository.url}", which does not match the ' + 'configured GitHub Actions automated publishing repository ' + '"$configuredGitHubRepository".', + ); + } + } + + return errors; +} diff --git a/app/lib/package/backend.dart b/app/lib/package/backend.dart index 67de342840..94a5c2d5cf 100644 --- a/app/lib/package/backend.dart +++ b/app/lib/package/backend.dart @@ -53,6 +53,7 @@ import '../shared/redis_cache.dart' show cache, EntryPurgeExt; import '../shared/storage.dart'; import '../shared/urls.dart' as urls; import '../shared/utils.dart'; +import 'attestation_verifier.dart'; import 'model_properties.dart'; import 'models.dart'; import 'name_tracker.dart'; @@ -1508,6 +1509,38 @@ class PackageBackend { maxSize: maxAttestationContentLength, ), ); + _logger.info('Verifying package attestation ($uploadGuid).'); + final existingPackage = await lookupPackage(pubspec.name); + final githubConfig = existingPackage?.publishingConfig?.githubConfig; + final configuredGitHubRepository = + (githubConfig != null && githubConfig.isEnabled) + ? githubConfig.repository + : null; + + // The signature is verified over the digest computed above from the + // very file that is being published; the archive is not read again. + // + // Only failures attributable to the uploaded bundle are reported as a + // failed result, anything else (e.g. the verifier not being available) + // propagates and fails the request with a logged internal error. + final verificationResult = attestationVerifier.verify( + packageName: pubspec.name, + packageVersion: Version.parse(versionString), + archiveSha256: sha256Hash, + bundleJson: attestationContent, + pubspecRepository: pubspec.repository?.toString(), + configuredGitHubRepository: configuredGitHubRepository, + ); + if (!verificationResult.isValid) { + throw PackageRejectedException( + 'Invalid package attestation: ' + '${verificationResult.errors.join(', ')}', + ); + } + _logger.info( + 'Attestation verified successfully for ${pubspec.name} $versionString ' + 'from repository ${verificationResult.repository}.', + ); } sw.reset(); diff --git a/app/lib/service/entrypoint/frontend.dart b/app/lib/service/entrypoint/frontend.dart index 8fed3d7cde..c5fc0e5d2e 100644 --- a/app/lib/service/entrypoint/frontend.dart +++ b/app/lib/service/entrypoint/frontend.dart @@ -15,6 +15,7 @@ import 'package:watcher/watcher.dart'; import '../../frontend/handlers.dart'; import '../../frontend/static_files.dart'; +import '../../package/attestation_verifier.dart'; import '../../package/name_tracker.dart'; import '../../search/top_packages.dart'; import '../../service/announcement/backend.dart'; @@ -41,6 +42,11 @@ class DefaultCommand extends Command { } Future _main() async { + // Fail at startup, rather than on every attested upload, when the native + // library used to verify attestations cannot be loaded (e.g. the snapshot + // was built without its native assets mapping). + attestationVerifier.checkNativeLibrary(); + if (envConfig.isRunningLocally) { await updateLocalBuiltFilesIfNeeded(); await watchForResourceChanges(); diff --git a/app/lib/service/entrypoint/self_check.dart b/app/lib/service/entrypoint/self_check.dart new file mode 100644 index 0000000000..fff9239166 --- /dev/null +++ b/app/lib/service/entrypoint/self_check.dart @@ -0,0 +1,29 @@ +// 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:args/command_runner.dart'; + +import '../../package/attestation_verifier.dart'; + +/// Checks that the running binary is able to use its native dependencies. +/// +/// Used to verify the production container image after it has been built, +/// see `app/test/service/entrypoint/dockerfile_app_build_test.dart`. +class SelfCheckCommand extends Command { + @override + String get name => 'self-check'; + + @override + String get description => + 'Checks that the binary can load its native dependencies.'; + + @override + bool get hidden => true; + + @override + Future run() async { + AttestationVerifier().checkNativeLibrary(); + print('self-check: OK'); + } +} diff --git a/app/lib/service/services.dart b/app/lib/service/services.dart index 19ae623025..87b4387353 100644 --- a/app/lib/service/services.dart +++ b/app/lib/service/services.dart @@ -28,12 +28,14 @@ import '../account/default_auth_provider.dart'; import '../account/like_backend.dart'; import '../admin/backend.dart'; import '../audit/backend.dart'; +import '../fake/backend/fake_attestation_verifier.dart'; import '../fake/backend/fake_auth_provider.dart'; import '../fake/backend/fake_domain_verifier.dart'; import '../fake/backend/fake_email_sender.dart'; import '../fake/backend/fake_upload_signer_service.dart'; import '../fake/server/fake_storage_server.dart'; import '../frontend/handlers.dart'; +import '../package/attestation_verifier.dart'; import '../package/backend.dart'; import '../package/name_tracker.dart'; import '../package/search_adapter.dart'; @@ -95,6 +97,7 @@ Future withServices(FutureOr Function() fn) async { registerAuthProvider(DefaultAuthProvider()); registerScopeExitCallback(authProvider.close); registerDomainVerifier(DomainVerifier()); + registerAttestationVerifier(AttestationVerifier()); registerEmailSender( activeConfiguration.gmailRelayServiceAccount != null && activeConfiguration.isProduction @@ -188,6 +191,7 @@ Future withFakeServices({ registerAuthProvider(FakeAuthProvider()); registerScopeExitCallback(authProvider.close); registerDomainVerifier(FakeDomainVerifier()); + registerAttestationVerifier(FakeAttestationVerifier()); registerEmailSender( FakeEmailSender(outputDir: envConfig.fakeEmailSenderOutputDir), ); diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 36f89711b8..18c844142f 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -56,6 +56,7 @@ dependencies: ulid: '2.2.0' tar: '2.0.2' api_builder: + sigstore: ^0.1.0 dev_dependencies: build_runner: '^2.0.0' diff --git a/app/test/package/upload_test.dart b/app/test/package/upload_test.dart index b7c9380d8e..a197cac246 100644 --- a/app/test/package/upload_test.dart +++ b/app/test/package/upload_test.dart @@ -4,10 +4,12 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:typed_data'; import 'package:_pub_shared/data/admin_api.dart'; import 'package:_pub_shared/data/package_api.dart'; import 'package:clock/clock.dart'; +import 'package:crypto/crypto.dart'; import 'package:gcloud/db.dart'; import 'package:gcloud/storage.dart'; import 'package:pub_dev/account/backend.dart'; @@ -16,9 +18,11 @@ import 'package:pub_dev/audit/backend.dart'; import 'package:pub_dev/audit/models.dart'; import 'package:pub_dev/database/database.dart'; import 'package:pub_dev/database/schema.dart'; +import 'package:pub_dev/fake/backend/fake_attestation_verifier.dart'; import 'package:pub_dev/fake/backend/fake_auth_provider.dart'; import 'package:pub_dev/fake/backend/fake_email_sender.dart'; import 'package:pub_dev/frontend/handlers/pubapi.client.dart'; +import 'package:pub_dev/package/attestation_verifier.dart'; import 'package:pub_dev/package/backend.dart'; import 'package:pub_dev/package/models.dart'; import 'package:pub_dev/package/name_tracker.dart'; @@ -28,6 +32,7 @@ import 'package:pub_dev/service/secret/backend.dart'; import 'package:pub_dev/shared/configuration.dart'; import 'package:pub_dev/shared/exceptions.dart'; import 'package:pub_dev/tool/test_profile/models.dart'; +import 'package:pub_semver/pub_semver.dart'; import 'package:test/test.dart'; import 'package:typed_sql/typed_sql.dart' hide AuthenticationException; import 'package:yaml/yaml.dart'; @@ -1724,22 +1729,18 @@ void main() { ], fn: () async { final pubspecContent = - 'name: attested_pkg\nversion: 1.0.0\ndescription: A package with attestation.\nenvironment:\n sdk: ">=2.12.0 <4.0.0"\n'; + 'name: attested_pkg\nversion: 1.0.0\ndescription: A package with attestation.\nrepository: https://github.com/mosuem/attested_pkg\nenvironment:\n sdk: ">=2.12.0 <4.0.0"\n'; final archiveBytes = await packageArchiveBytes( pubspecContent: pubspecContent, ); - final bundleJson = { - 'mediaType': 'application/vnd.dev.sigstore.bundle.v0.3+json', - 'verificationMaterial': {}, - 'dsseEnvelope': { - 'payloadType': 'application/vnd.in-toto+json', - 'payload': base64Encode(utf8.encode('{}')), - 'signatures': [], - }, - }; + final bundleJson = _buildProvenanceBundleJson( + subjectName: 'attested_pkg-1.0.0.tar.gz', + subjectSha256: sha256.convert(archiveBytes).toString(), + repository: 'https://github.com/mosuem/attested_pkg', + ); // Verbatim is what the spec promises: keep the odd formatting. final attestationBytes = utf8.encode( - ' ${jsonEncode(bundleJson).replaceAll(',', ',\n ')}\n', + ' ${bundleJson.replaceAll(',', ',\n ')}\n', ); final client = createPubApiClient(authToken: adminClientToken); @@ -1850,19 +1851,26 @@ void main() { ], fn: () async { final pubspecContent = - 'name: attested_pkg\nversion: 1.0.0\ndescription: A package with attestation.\nenvironment:\n sdk: ">=2.12.0 <4.0.0"\n'; + 'name: attested_pkg\nversion: 1.0.0\ndescription: A package with attestation.\nrepository: https://github.com/mosuem/attested_pkg\nenvironment:\n sdk: ">=2.12.0 <4.0.0"\n'; final archiveBytes = await packageArchiveBytes( pubspecContent: pubspecContent, ); + final attestationBytes = utf8.encode( + _buildProvenanceBundleJson( + subjectName: 'attested_pkg-1.0.0.tar.gz', + subjectSha256: sha256.convert(archiveBytes).toString(), + repository: 'https://github.com/mosuem/attested_pkg', + ), + ); final client = createPubApiClient(authToken: adminClientToken); await client.uploadPackageBytes( archiveBytes, - attestationBytes: utf8.encode('{"mediaType": "test"}'), + attestationBytes: attestationBytes, ); await asyncQueue.ongoingProcessing; expect( await client.getPackageVersionAttestation('attested_pkg', '1.0.0'), - utf8.encode('{"mediaType": "test"}'), + attestationBytes, ); await createPubApiClient(authToken: siteAdminToken).adminInvokeAction( @@ -1891,6 +1899,264 @@ void main() { }, ); + testWithProfile( + 'upload fails when provenance repository mismatches pubspec.yaml or configured GitHub repository', + fn: () async { + // 1. Mismatch between provenance repository and pubspec.yaml repository + final pubspecContent = + 'name: mismatched_repo_pkg\nversion: 1.0.0\ndescription: Package with mismatched repo.\nrepository: https://github.com/dart-lang/mismatched_repo_pkg\nenvironment:\n sdk: ">=2.12.0 <4.0.0"\n'; + final archiveBytes = await packageArchiveBytes( + pubspecContent: pubspecContent, + ); + final mismatchedBundle = _buildProvenanceBundleJson( + subjectName: 'mismatched_repo_pkg-1.0.0.tar.gz', + subjectSha256: sha256.convert(archiveBytes).toString(), + repository: 'https://github.com/evil/mismatched_repo_pkg', + ); + final rs1 = createPubApiClient(authToken: adminClientToken) + .uploadPackageBytes( + archiveBytes, + attestationBytes: utf8.encode(mismatchedBundle), + ); + await expectApiException( + rs1, + status: 400, + code: 'PackageRejected', + message: + 'Attestation states the package was built from "https://github.com/evil/mismatched_repo_pkg", but the pubspec.yaml of mismatched_repo_pkg 1.0.0 declares `repository: https://github.com/dart-lang/mismatched_repo_pkg`.', + ); + + // 2. Mismatch between provenance repository and pub.dev's stored + // GitHub Actions automated publishing configuration (even when + // pubspec.yaml inside the tarball self-asserts the attacker's repo). + await withFakeAuthRetryPubApiClient(email: adminAtPubDevEmail, ( + client, + ) async { + await client.setAutomatedPublishing( + 'oxygen', + PkgPublishingConfig( + github: GitHubPublishingConfig( + isEnabled: true, + repository: 'dart-lang/oxygen', + tagPattern: '{{version}}', + ), + ), + ); + }); + + final oxygenPubspec = + 'name: oxygen\nversion: 2.2.0\ndescription: oxygen is awesome\nrepository: https://github.com/evil/oxygen\nenvironment:\n sdk: ">=2.12.0 <4.0.0"\n'; + final oxygenBytes = await packageArchiveBytes( + pubspecContent: oxygenPubspec, + ); + final spoofedBundle = _buildProvenanceBundleJson( + subjectName: 'oxygen-2.2.0.tar.gz', + subjectSha256: sha256.convert(oxygenBytes).toString(), + repository: 'https://github.com/evil/oxygen', + ); + final rs2 = createPubApiClient(authToken: adminClientToken) + .uploadPackageBytes( + oxygenBytes, + attestationBytes: utf8.encode(spoofedBundle), + ); + await expectApiException( + rs2, + status: 400, + code: 'PackageRejected', + message: + 'Attestation states the package was built from "https://github.com/evil/oxygen", which does not match the configured GitHub Actions automated publishing repository "dart-lang/oxygen".', + ); + + // 3. Untrusted signing workflow rejected + final untrustedBuilderBundle = _buildProvenanceBundleJson( + subjectName: 'mismatched_repo_pkg-1.0.0.tar.gz', + subjectSha256: sha256.convert(archiveBytes).toString(), + repository: 'https://github.com/dart-lang/mismatched_repo_pkg', + builderId: + 'https://github.com/evil/workflows/.github/workflows/publish.yml@refs/tags/v1', + ); + final rs3 = createPubApiClient(authToken: adminClientToken) + .uploadPackageBytes( + archiveBytes, + attestationBytes: utf8.encode(untrustedBuilderBundle), + ); + await expectApiException( + rs3, + status: 400, + code: 'PackageRejected', + message: 'not a workflow trusted to publish packages', + ); + }, + ); + + testWithProfile( + 'upload fails when the attestation is for a different archive', + fn: () async { + final pubspecContent = + 'name: tampered_pkg\nversion: 1.0.0\ndescription: Archive differs from the attested one.\nrepository: https://github.com/mosuem/tampered_pkg\nenvironment:\n sdk: ">=2.12.0 <4.0.0"\n'; + final archiveBytes = await packageArchiveBytes( + pubspecContent: pubspecContent, + ); + final otherSha256 = sha256.convert(utf8.encode('other')).toString(); + final bundleJson = _buildProvenanceBundleJson( + subjectName: 'tampered_pkg-1.0.0.tar.gz', + subjectSha256: otherSha256, + repository: 'https://github.com/mosuem/tampered_pkg', + ); + final rs = createPubApiClient(authToken: adminClientToken) + .uploadPackageBytes( + archiveBytes, + attestationBytes: utf8.encode(bundleJson), + ); + await expectApiException( + rs, + status: 400, + code: 'PackageRejected', + message: + 'Attestation is for an archive with sha256 "$otherSha256", but the uploaded archive has sha256 "${sha256.convert(archiveBytes)}".', + ); + }, + ); + + test('verifies a real signed package attestation and provenance policies', () { + final verifier = AttestationVerifier(); + final helpfulSha256 = sha256.convert(_helpfulArchiveBytes).bytes; + + final validResult = verifier.verify( + packageName: 'helpful', + packageVersion: Version(0, 1, 5), + archiveSha256: helpfulSha256, + bundleJson: _helpfulBundleJson, + pubspecRepository: 'https://github.com/mosuem/helpful', + configuredGitHubRepository: 'mosuem/helpful', + ); + expect(validResult.errors, isEmpty); + expect(validResult.isValid, isTrue); + expect(validResult.repository, 'https://github.com/mosuem/helpful'); + expect( + validResult.provenance?.commitSha, + '63216a3022ed606538399634cd1c525f8bd4657f', + ); + + final wrongPubspecRepo = verifier.verify( + packageName: 'helpful', + packageVersion: Version(0, 1, 5), + archiveSha256: helpfulSha256, + bundleJson: _helpfulBundleJson, + pubspecRepository: 'https://github.com/evil/helpful', + ); + expect(wrongPubspecRepo.isValid, isFalse); + expect( + wrongPubspecRepo.errors.single, + contains( + 'Attestation states the package was built from "https://github.com/mosuem/helpful", but the pubspec.yaml of helpful 0.1.5 declares `repository: https://github.com/evil/helpful`.', + ), + ); + + final wrongConfiguredRepo = verifier.verify( + packageName: 'helpful', + packageVersion: Version(0, 1, 5), + archiveSha256: helpfulSha256, + bundleJson: _helpfulBundleJson, + pubspecRepository: 'https://github.com/mosuem/helpful', + configuredGitHubRepository: 'dart-lang/helpful', + ); + expect(wrongConfiguredRepo.isValid, isFalse); + expect( + wrongConfiguredRepo.errors.single, + contains( + 'does not match the configured GitHub Actions automated publishing repository "dart-lang/helpful"', + ), + ); + + // The signature is over the archive digest: a different archive fails + // signature verification before any policy is evaluated. + final wrongArchive = verifier.verify( + packageName: 'helpful', + packageVersion: Version(0, 1, 5), + archiveSha256: sha256.convert(utf8.encode('other')).bytes, + bundleJson: _helpfulBundleJson, + pubspecRepository: 'https://github.com/mosuem/helpful', + ); + expect(wrongArchive.isValid, isFalse); + expect( + wrongArchive.errors.single, + 'Attestation signature verification failed.', + ); + + // A bundle with a plain artifact signature (no dsseEnvelope) is not a + // valid attestation for the archive. + final noDsseResult = verifier.verify( + packageName: 'helpful', + packageVersion: Version(0, 1, 5), + archiveSha256: helpfulSha256, + bundleJson: _sampleBundleJson, + pubspecRepository: 'https://github.com/mosuem/helpful', + ); + expect(noDsseResult.isValid, isFalse); + + // Not a Sigstore bundle at all. + final notABundle = verifier.verify( + packageName: 'helpful', + packageVersion: Version(0, 1, 5), + archiveSha256: helpfulSha256, + bundleJson: '{"hello": "world"}', + pubspecRepository: 'https://github.com/mosuem/helpful', + ); + expect(notABundle.isValid, isFalse); + expect( + notABundle.errors.single, + 'Attestation is not a valid Sigstore bundle.', + ); + }); + + test('fake verifier reports a bundle without provenance', () { + final noDsseResult = FakeAttestationVerifier().verify( + packageName: 'helpful', + packageVersion: Version(0, 1, 5), + archiveSha256: sha256.convert(_helpfulArchiveBytes).bytes, + bundleJson: _sampleBundleJson, + pubspecRepository: 'https://github.com/mosuem/helpful', + ); + expect(noDsseResult.isValid, isFalse); + expect( + noDsseResult.errors.single, + contains('carries no build provenance'), + ); + }); + + test('internal verification errors are not reported as invalid', () { + final digest = sha256.convert(_helpfulArchiveBytes).bytes; + expect( + () => _ThrowingAttestationVerifier(SigstoreError.internalError).verify( + packageName: 'helpful', + packageVersion: Version(0, 1, 5), + archiveSha256: digest, + bundleJson: _helpfulBundleJson, + ), + throwsA(SigstoreError.internalError), + ); + expect( + () => _ThrowingAttestationVerifier(StateError('no native library')) + .verify( + packageName: 'helpful', + packageVersion: Version(0, 1, 5), + archiveSha256: digest, + bundleJson: _helpfulBundleJson, + ), + throwsStateError, + ); + expect( + () => AttestationVerifier().verify( + packageName: 'helpful', + packageVersion: Version(0, 1, 5), + archiveSha256: [1, 2, 3], + bundleJson: _helpfulBundleJson, + ), + throwsArgumentError, + ); + }); + testWithProfile( 'retrieving attestation of a package without attestation returns 404', fn: () async { @@ -2008,5 +2274,104 @@ void main() { ); }, ); + + testWithProfile( + 'upload fails when attestation is invalid or sha256 does not match', + fn: () async { + final pubspecContent = + 'name: invalid_attested_pkg\nversion: 1.0.0\ndescription: An invalid attested package.\nrepository: https://github.com/mosuem/invalid_attested_pkg\nenvironment:\n sdk: ">=2.12.0 <4.0.0"\n'; + final archiveBytes = await packageArchiveBytes( + pubspecContent: pubspecContent, + ); + + final attestationBytes = utf8.encode(_sampleBundleJson); + final rs = createPubApiClient( + authToken: adminClientToken, + ).uploadPackageBytes(archiveBytes, attestationBytes: attestationBytes); + await expectApiException( + rs, + status: 400, + code: 'PackageRejected', + message: 'Invalid package attestation', + ); + }, + ); }); } + +/// An [AttestationVerifier] whose signature verification fails with [error]. +class _ThrowingAttestationVerifier extends AttestationVerifier { + final Object error; + _ThrowingAttestationVerifier(this.error); + + @override + VerifiedSigner verifySignature({ + required List archiveSha256, + required String bundleJson, + }) { + Error.throwWithStackTrace(error, StackTrace.current); + } +} + +String _buildProvenanceBundleJson({ + required String subjectName, + required String subjectSha256, + required String repository, + String ref = 'refs/tags/v1.0.0', + String workflowPath = '.github/workflows/publish.yml', + String commitSha = 'c0ffee7891abbe3dab159e9d0187fc1042d5e0cd', + String builderId = + 'https://github.com/dart-lang/setup-dart/.github/workflows/publish.yml@refs/tags/v2', +}) { + final statement = { + '_type': 'https://in-toto.io/Statement/v1', + 'subject': [ + { + 'name': subjectName, + 'digest': {'sha256': subjectSha256}, + }, + ], + 'predicateType': 'https://slsa.dev/provenance/v1', + 'predicate': { + 'buildDefinition': { + 'buildType': 'https://actions.github.io/buildtypes/workflow/v1', + 'externalParameters': { + 'workflow': { + 'ref': ref, + 'repository': repository, + 'path': workflowPath, + }, + }, + 'resolvedDependencies': [ + { + 'uri': 'git+$repository@$ref', + 'digest': {'gitCommit': commitSha}, + }, + ], + }, + 'runDetails': { + 'builder': {'id': builderId}, + }, + }, + }; + return jsonEncode({ + 'mediaType': 'application/vnd.dev.sigstore.bundle+json;version=0.3', + 'dsseEnvelope': { + 'payload': base64Encode(utf8.encode(jsonEncode(statement))), + 'payloadType': 'application/vnd.in-toto+json', + 'signatures': [ + {'sig': 'not-a-real-signature'}, + ], + }, + }); +} + +final Uint8List _helpfulArchiveBytes = base64Decode( + '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', +); + +const String _helpfulBundleJson = + r'''{"mediaType":"application/vnd.dev.sigstore.bundle.v0.3+json","verificationMaterial":{"certificate":{"rawBytes":"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"},"tlogEntries":[{"logIndex":"2676401329","logId":{"keyId":"wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="},"kindVersion":{"kind":"dsse","version":"0.0.1"},"integratedTime":"1788270157","inclusionPromise":{"signedEntryTimestamp":"MEYCIQCF0fJPsDlo0/ETbGoj7P1Tv5t6/KnFTC4li5/hgfp+zQIhAMHiH5jQct+InQUDaLsvrj4ScaLttabrbOtgOOFNP5TB"},"inclusionProof":{"logIndex":"2554497067","rootHash":"3VYInn5w29R1Ik5hPXoKNUXLSpjYDA+eOe15tHZDf3Q=","treeSize":"2554497077","hashes":["vEJRC4WGQtYk/U8bQHZ1GSxv9gXkeDlMle2iNtj5oLk=","A+Z5tXgPG9hRnNyqfrNlhB/lSijIrKbBlghyVizPfDw=","34bE/cxzMRK5jIX0UnN60fixf7k7tlZnLfYP9WwTbAA=","xF6ied/Idor3bSwQm8r7l+4G+22wzhE4Yyo2TSTTJzU=","mI6+Jl7u+5zbsD2btfPoIi4JXtac2ZA3T4OQu5XBQv0=","+kWrmBfcvkeD3p6uiuVZRX0NzFhOAsr66BfgZXcY/gE=","Rt2No6mKQC0/Lm4I88OyNhBZctDQArHU/FWcBQXRKHk=","YnrIcUp47OZh4DKzWnPt6HbUJ47Z6J/60daOiLSJFhM=","PSk3xvEFw94Y6oOvFw7ze0gTgr7OTNkRO9qcIOHxQWc=","/7Mrva+smcuOSAUamgAAnlU6WDjX01mVt0u9CfyOS3w=","eXSLrLqKSxRTixSP/k4YimriB2lufao6trqTrsxxlyg=","SndbMKVtcTenAkwi2JBfGzD+mhexp1qJbRIY+A1JRIU=","xH/DCseLHr9eKoYT8qsORZK7zVdEGYWHuVtsVrD95wY="],"checkpoint":{"envelope":"rekor.sigstore.dev - 1193050959916656506\n2554497077\n3VYInn5w29R1Ik5hPXoKNUXLSpjYDA+eOe15tHZDf3Q=\n\n\u2014 rekor.sigstore.dev wNI9ajBFAiEA+vbOqxP76bar3G0xXiMl0qAQ249eIvk2LPNvYJjep64CIGC3cdY2YUlaa6vc0djTkh1irUcugzw9PpnCKsURbPHp\n"}},"canonicalizedBody":"eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiZHNzZSIsInNwZWMiOnsiZW52ZWxvcGVIYXNoIjp7ImFsZ29yaXRobSI6InNoYTI1NiIsInZhbHVlIjoiZDNkOTMxYjZjN2VjNWNhZjRmOTA0MWQwOWNhZTk4ODY5MjhlODUzMDg1NGY3NzQzMmZkY2QxNWYxM2Q4NjU4YSJ9LCJwYXlsb2FkSGFzaCI6eyJhbGdvcml0aG0iOiJzaGEyNTYiLCJ2YWx1ZSI6IjFhOWQ5MTFmZDBkM2Y2NGY1NmY1ZjZjNGIxMTEwN2VmMzY2MmI0ZGViZGM3YmJjZjY2NTliMzQ4NzY5NzQ4OWQifSwic2lnbmF0dXJlcyI6W3sic2lnbmF0dXJlIjoiTUVZQ0lRRGgzeXY1MXREZ1Q5UWFHemh0VXRBSEFsMWpQa2dqSXF2dlZBcFBmWUNaa3dJaEFNNExlaTZvZDFOc1RrdmZOY21zeVlyT3A0azRhNDljYlhWYVRPK0hoTzAwIiwidmVyaWZpZXIiOiJMUzB0TFMxQ1JVZEpUaUJEUlZKVVNVWkpRMEZVUlMwdExTMHRDazFKU1VoVFZFTkRRbk1yWjBGM1NVSkJaMGxWVTJKTGNuWTFjbkp5VlZVclZtOUtVRE5FYzFaelRVSlZhMkZOZDBObldVbExiMXBKZW1vd1JVRjNUWGNLVG5wRlZrMUNUVWRCTVZWRlEyaE5UV015Ykc1ak0xSjJZMjFWZFZwSFZqSk5ValIzU0VGWlJGWlJVVVJGZUZaNllWZGtlbVJIT1hsYVV6RndZbTVTYkFwamJURnNXa2RzYUdSSFZYZElhR05PVFdwWmQwOVVRWGhOVkUwd1RXcE5NMWRvWTA1TmFsbDNUMVJCZUUxVVRURk5hazB6VjJwQlFVMUdhM2RGZDFsSUNrdHZXa2w2YWpCRFFWRlpTVXR2V2tsNmFqQkVRVkZqUkZGblFVVnpNRmxLVTJSVU1saE9hRll3VUV0Q1lrUndOMVZ5V0hseU9FTTFjR2t4UTNrNFlsUUtNVmMwWkRKNWNEYzJhRzlrUTFZd0szUnZMMVo0WVhjeVJrTnVTa3R4WkVSNlpGWXJRMHR1WW1VeVlrVTVWM0pVYWt0UFEwSmxOSGRuWjFoeFRVRTBSd3BCTVZWa1JIZEZRaTkzVVVWQmQwbElaMFJCVkVKblRsWklVMVZGUkVSQlMwSm5aM0pDWjBWR1FsRmpSRUY2UVdSQ1owNVdTRkUwUlVablVWVnZlakZRQ25JdlZsbFBWVWRCYnpSRWJrbE5lWGRHTlU5UE9VTmpkMGgzV1VSV1VqQnFRa0puZDBadlFWVXpPVkJ3ZWpGWmEwVmFZalZ4VG1wd1MwWlhhWGhwTkZrS1drUTRkMlYzV1VSV1VqQlNRVkZJTDBKSVJYZGlORnAwWVVoU01HTklUVFpNZVRsdVlWaFNiMlJYU1hWWk1qbDBUREpTYUdOdVVYUmlSMFoxV25rNWVncGFXRkl4WTBNeGExbFlTakJNZVRWdVlWaFNiMlJYU1haa01qbDVZVEphYzJJelpIcE1NMEl4V1cxNGNHTXlaM1ZsVnpGelVVaEtiRnB1VFhaaFIxWm9DbHBJVFhaalNGWnBZa2RzZW1GRE1YZFpWMDV5V1Zka2JFeFhSakJrUjFaNlpFZEdNR0ZYT1hWamVrRTFRbWR2Y2tKblJVVkJXVTh2VFVGRlFrSkRkRzhLWkVoU2QyTjZiM1pNTTFKMllUSldkVXh0Um1wa1IyeDJZbTVOZFZveWJEQmhTRlpwWkZoT2JHTnRUblppYmxKc1ltNVJkVmt5T1hSTlFqaEhRMmx6UndwQlVWRkNaemM0ZDBGUlNVVkZXR1IyWTIxMGJXSkhPVE5ZTWxKd1l6TkNhR1JIVG05TlJGbEhRMmx6UjBGUlVVSm5OemgzUVZGTlJVdEVXWHBOYWtVeUNsbFVUWGROYWtwc1drUlpkMDVxVlhwUFJFMDFUMVJaZWs1SFRtdE5WMDB4VFdwV2JVOUhTbXRPUkZreFRqSlpkMHRCV1V0TGQxbENRa0ZIUkhaNlFVSUtRa0ZSWVZWSVZtbGlSMng2WVVOQ2IxcFhlSGRhYmxaelNVaFNka2xJUWpGWmFUVnJXbGhaZDBoQldVdExkMWxDUWtGSFJIWjZRVUpDVVZGUFlsYzVlZ3BrVjFaMFRESm9iR0pJUW0xa1YzZDNTRkZaUzB0M1dVSkNRVWRFZG5wQlFrSm5VVkJqYlZadFkzazViMXBYUm10amVUbDBXVmRzZFUxRWMwZERhWE5IQ2tGUlVVSm5OemgzUVZGblJVeFJkM0poU0ZJd1kwaE5Oa3g1T1RCaU1uUnNZbWsxYUZrelVuQmlNalY2VEcxa2NHUkhhREZaYmxaNldsaEthbUl5TlRBS1dsYzFNRXh0VG5aaVZFSTVRbWR2Y2tKblJVVkJXVTh2VFVGRlNrSkhPRTFpVjJnd1pFaENlazlwT0haYU1td3dZVWhXYVV4dFRuWmlVemxyV1ZoS01BcE1WM2hvWW0xamRtTXlWakJrV0VGMFdrZEdlV1JET0hWYU1td3dZVWhXYVV3elpIWmpiWFJ0WWtjNU0yTjVPWGRrVjBwellWaE9iMHh1YkhSaVJVSjVDbHBYV25wTU1taHNXVmRTZWt3elFqRlpiWGh3WXpKbmRHTkhSbXBoTWtadVdsTXhhR1JJVW14ak0xSm9aRWRzZG1KdVRYZFBRVmxMUzNkWlFrSkJSMFFLZG5wQlFrTm5VWEZFUTJocldtcGtiVmxxWkdoT2JVMTNXV3BuTlUxNlJYZGFWR2Q2VFVSbk1sbHRVVEpOVjBrelRucHJNVTR5VW0xUFZGbDVUMWROTUFwTlFqQkhRMmx6UjBGUlVVSm5OemgzUVZGelJVUjNkMDVhTW13d1lVaFdhVXhYYUhaak0xSnNXa1JCZUVKbmIzSkNaMFZGUVZsUEwwMUJSVTFDUTAxTkNrbFhhREJrU0VKNlQyazRkbG95YkRCaFNGWnBURzFPZG1KVE9YUmlNMDR4V2xjd2RtRkhWbk5qUjFveFlrUkJORUpuYjNKQ1owVkZRVmxQTDAxQlJVNEtRa052VFV0RVdYcE5ha1V5V1ZSTmQwMXFTbXhhUkZsM1RtcFZlazlFVFRWUFZGbDZUa2RPYTAxWFRURk5hbFp0VDBkS2EwNUVXVEZPTWxsM1NIZFpTd3BMZDFsQ1FrRkhSSFo2UVVKRVoxRlNSRUU1ZVZwWFducE1NbWhzV1ZkU2Vrd3lNV2hoVnpSM1IyZFpTMHQzV1VKQ1FVZEVkbnBCUWtSM1VVMUVRVzk0Q2sxNlRUVk5la1Y2VFVSSk1VMURhMGREYVhOSFFWRlJRbWMzT0hkQlVrRkZSM2QzV21GSVVqQmpTRTAyVEhrNWJtRllVbTlrVjBsMVdUSTVkRXd5TVhZS1l6TldiR0pVUVZsQ1oyOXlRbWRGUlVGWlR5OU5RVVZTUWtGdlRVTkVTVE5QUkZVMVRVUm5lVTFIUVVkRGFYTkhRVkZSUW1jM09IZEJVa2xGVldkNFVRcGhTRkl3WTBoTk5reDVPVzVoV0ZKdlpGZEpkVmt5T1hSTU1qRjJZek5XYkdKVE9XOWFWM2gzV201V2MweDVOVzVoV0ZKdlpGZEpkbVF5T1hsaE1scHpDbUl6WkhwTU0wSXhXVzE0Y0dNeVozVmxWMFowWWtWQ2VWcFhXbnBNTW1oc1dWZFNla3d5TVdoaFZ6UjNUMEZaUzB0M1dVSkNRVWRFZG5wQlFrVjNVWEVLUkVObk1rMTZTWGhPYlVWNlRVUkplVnBYVVRKTlJGa3hUWHBuZWs5VWF6Sk5lbEpxV2tSR2FrNVVTVEZhYW1ocFdrUlJNazVVWkcxTlEwVkhRMmx6UndwQlVWRkNaemM0ZDBGU1VVVkZkM2RTWkRJNWVXRXlXbk5pTTJSbVdrZHNlbU5IUmpCWk1tZDNWbEZaUzB0M1dVSkNRVWRFZG5wQlFrWlJVa2hFUlZadkNtUklVbmRqZW05MlRESmtjR1JIYURGWmFUVnFZakl3ZG1KWE9YcGtWMVowVERKb2JHSklRbTFrVjNkMldWZE9NR0ZYT1hWamVUbDVaRmMxZWt4NlRYb0tUbFJGTUU5VVVYcE9WRlV4VERKR01HUkhWblJqU0ZKNlRIcEZkMFpuV1V0TGQxbENRa0ZIUkhaNlFVSkdaMUZKUkVGYWQyUlhTbk5oVjAxM1UzZFpTd3BMZDFsQ1FrRkhSSFo2UVVKSFFWRTVSRVIwZVZwWVFuWlBiVEYyWXpOV2JHSlZRWGxPZW1jeFQxUkJORTFwT1c5YVYzaDNXbTVXYzFGRVJYcE5lbXQ2Q2sxVVRYZE5hbFUyWTIxV2JVOXVTbXhhYmsxMllVZFdhRnBJVFhaaVYwWndZbXBEUW1sM1dVdExkMWxDUWtGSVYyVlJTVVZCWjFJNVFraHpRV1ZSUWpNS1FVNHdPVTFIY2tkNGVFVjVXWGhyWlVoS2JHNU9kMHRwVTJ3Mk5ETnFlWFF2TkdWTFkyOUJka3RsTms5QlFVRkNiMFl3TVVodk5FRkJRVkZFUVVWbmR3cFNaMGxvUVV0c2RISTFaazlNV0djNFZqVnplRU13YzJKS2IwUm1kbWRzVm13MU5sSnZkSEp1T0hWUVYySnRjVzFCYVVWQk5USkRVWFY1WWxCSWRsRmpDbFV3YUN0c05DdGFZbUVyWlVoak9XdFFTV1J0TnpCVk5WVnVkRmhWZW5kM1EyZFpTVXR2V2tsNmFqQkZRWGROUkdGQlFYZGFVVWwzVG5CWmRtdEdlV1FLYTBOeVJ6VTNjVXgwUWk5NVpVOWhSMVp6YzAxUGFsRnBURkY0VTBwNVNHZzViUzkwTDB4VVZXUnhlbWRaVDB0cWVVZENSV2RNYUcxQmFrVkJNbVJwWXdwbVZHaGtWakZUYWtkdVdEZGFPVFZWUzBreFNWQk5VV1pQVHpJek1rSjBjWEkzUVhFeWJGZHNUbWR3TW5oRFJXeHJXV0V2YjBKa1RXdGpSRUlLTFMwdExTMUZUa1FnUTBWU1ZFbEdTVU5CVkVVdExTMHRMUW89In1dfX0="}],"timestampVerificationData":{}},"dsseEnvelope":{"payload":"eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoicGFja2FnZS50YXIuZ3oiLCJkaWdlc3QiOnsic2hhMjU2IjoiYzhlY2ZhNjExMDBlY2UxYTU1ZmNiNTI3MTZkYjc0ZjkyYjZkZWJlYjc1NzdhNGI4Y2E0NTQ4ZjQ0MGJmYTZiZiJ9fV0sInByZWRpY2F0ZVR5cGUiOiJodHRwczovL3Nsc2EuZGV2L3Byb3ZlbmFuY2UvdjEiLCJwcmVkaWNhdGUiOnsiYnVpbGREZWZpbml0aW9uIjp7ImJ1aWxkVHlwZSI6Imh0dHBzOi8vYWN0aW9ucy5naXRodWIuaW8vYnVpbGR0eXBlcy93b3JrZmxvdy92MSIsImV4dGVybmFsUGFyYW1ldGVycyI6eyJ3b3JrZmxvdyI6eyJyZWYiOiJyZWZzL2hlYWRzL21haW4iLCJyZXBvc2l0b3J5IjoiaHR0cHM6Ly9naXRodWIuY29tL21vc3VlbS9oZWxwZnVsIiwicGF0aCI6Ii5naXRodWIvd29ya2Zsb3dzL3B1Ymxpc2gueWFtbCJ9fSwiaW50ZXJuYWxQYXJhbWV0ZXJzIjp7ImdpdGh1YiI6eyJldmVudF9uYW1lIjoid29ya2Zsb3dfZGlzcGF0Y2giLCJyZXBvc2l0b3J5X2lkIjoiMTMzOTMxMzAyNSIsInJlcG9zaXRvcnlfb3duZXJfaWQiOiIyNzg1OTA4MiIsInJ1bm5lcl9lbnZpcm9ubWVudCI6ImdpdGh1Yi1ob3N0ZWQifX0sInJlc29sdmVkRGVwZW5kZW5jaWVzIjpbeyJ1cmkiOiJnaXQraHR0cHM6Ly9naXRodWIuY29tL21vc3VlbS9oZWxwZnVsQHJlZnMvaGVhZHMvbWFpbiIsImRpZ2VzdCI6eyJnaXRDb21taXQiOiI2MzIxNmEzMDIyZWQ2MDY1MzgzOTk2MzRjZDFjNTI1ZjhiZDQ2NTdmIn19XX0sInJ1bkRldGFpbHMiOnsiYnVpbGRlciI6eyJpZCI6Imh0dHBzOi8vZ2l0aHViLmNvbS9kYXJ0LWxhbmcvc2V0dXAtZGFydC8uZ2l0aHViL3dvcmtmbG93cy9wdWJsaXNoLnltbEByZWZzL2hlYWRzL3B1Ymxpc2gtcGFja2FnZS1hdHRlc3RhdGlvbnMifSwibWV0YWRhdGEiOnsiaW52b2NhdGlvbklkIjoiaHR0cHM6Ly9naXRodWIuY29tL21vc3VlbS9oZWxwZnVsL2FjdGlvbnMvcnVucy8zMzUxNDk0MzU1NS9hdHRlbXB0cy8xIn19fX0=","payloadType":"application/vnd.in-toto+json","signatures":[{"sig":"MEYCIQDh3yv51tDgT9QaGzhtUtAHAl1jPkgjIqvvVApPfYCZkwIhAM4Lei6od1NsTkvfNcmsyYrOp4k4a49cbXVaTO+HhO00"}]}}'''; + +const _sampleBundleJson = + r'''{"mediaType": "application/vnd.dev.sigstore.bundle+json;version=0.3", "verificationMaterial": {"certificate": {"rawBytes": "MIIIMTCCB7egAwIBAgIUaL/tsmQTHk21mt1Uuk+w7avDBz4wCgYIKoZIzj0EAwMwNzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwHhcNMjQwMzE5MTcyNjI2WhcNMjQwMzE5MTczNjI2WjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE22S1j/NkEXzBPQAuamHXLpwx+RPnnzZQl/pkEZ8xorvKnzujCS1mVTBo9kBxmYWo2DHtyVyfgnuOqVTzLYmho6OCBtYwggbSMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQUFv1SCziEKN2rRyrjeVlFbSLg1/QwHwYDVR0jBBgwFoAU39Ppz1YkEZb5qNjpKFWixi4YZD8wgaUGA1UdEQEB/wSBmjCBl4aBlGh0dHBzOi8vZ2l0aHViLmNvbS9zaWdzdG9yZS1jb25mb3JtYW5jZS9leHRyZW1lbHktZGFuZ2Vyb3VzLXB1YmxpYy1vaWRjLWJlYWNvbi8uZ2l0aHViL3dvcmtmbG93cy9leHRyZW1lbHktZGFuZ2Vyb3VzLW9pZGMtYmVhY29uLnltbEByZWZzL2hlYWRzL21haW4wOQYKKwYBBAGDvzABAQQraHR0cHM6Ly90b2tlbi5hY3Rpb25zLmdpdGh1YnVzZXJjb250ZW50LmNvbTAfBgorBgEEAYO/MAECBBF3b3JrZmxvd19kaXNwYXRjaDA2BgorBgEEAYO/MAEDBChjN2IzZGZiMzM1ZjA1MWUxYzg2YmRhNGM3MTZmYWM5N2RmNjJhZDgxMC0GCisGAQQBg78wAQQEH0V4dHJlbWVseSBkYW5nZXJvdXMgT0lEQyBiZWFjb24wSQYKKwYBBAGDvzABBQQ7c2lnc3RvcmUtY29uZm9ybWFuY2UvZXh0cmVtZWx5LWRhbmdlcm91cy1wdWJsaWMtb2lkYy1iZWFjb24wHQYKKwYBBAGDvzABBgQPcmVmcy9oZWFkcy9tYWluMDsGCisGAQQBg78wAQgELQwraHR0cHM6Ly90b2tlbi5hY3Rpb25zLmdpdGh1YnVzZXJjb250ZW50LmNvbTCBpgYKKwYBBAGDvzABCQSBlwyBlGh0dHBzOi8vZ2l0aHViLmNvbS9zaWdzdG9yZS1jb25mb3JtYW5jZS9leHRyZW1lbHktZGFuZ2Vyb3VzLXB1YmxpYy1vaWRjLWJlYWNvbi8uZ2l0aHViL3dvcmtmbG93cy9leHRyZW1lbHktZGFuZ2Vyb3VzLW9pZGMtYmVhY29uLnltbEByZWZzL2hlYWRzL21haW4wOAYKKwYBBAGDvzABCgQqDChjN2IzZGZiMzM1ZjA1MWUxYzg2YmRhNGM3MTZmYWM5N2RmNjJhZDgxMB0GCisGAQQBg78wAQsEDwwNZ2l0aHViLWhvc3RlZDBeBgorBgEEAYO/MAEMBFAMTmh0dHBzOi8vZ2l0aHViLmNvbS9zaWdzdG9yZS1jb25mb3JtYW5jZS9leHRyZW1lbHktZGFuZ2Vyb3VzLXB1YmxpYy1vaWRjLWJlYWNvbjA4BgorBgEEAYO/MAENBCoMKGM3YjNkZmIzMzVmMDUxZTFjODZiZGE0YzcxNmZhYzk3ZGY2MmFkODEwHwYKKwYBBAGDvzABDgQRDA9yZWZzL2hlYWRzL21haW4wGQYKKwYBBAGDvzABDwQLDAk2MzI1OTY4OTcwNwYKKwYBBAGDvzABEAQpDCdodHRwczovL2dpdGh1Yi5jb20vc2lnc3RvcmUtY29uZm9ybWFuY2UwGQYKKwYBBAGDvzABEQQLDAkxMzE4MDQ1NjMwgaYGCisGAQQBg78wARIEgZcMgZRodHRwczovL2dpdGh1Yi5jb20vc2lnc3RvcmUtY29uZm9ybWFuY2UvZXh0cmVtZWx5LWRhbmdlcm91cy1wdWJsaWMtb2lkYy1iZWFjb24vLmdpdGh1Yi93b3JrZmxvd3MvZXh0cmVtZWx5LWRhbmdlcm91cy1vaWRjLWJlYWNvbi55bWxAcmVmcy9oZWFkcy9tYWluMDgGCisGAQQBg78wARMEKgwoYzdiM2RmYjMzNWYwNTFlMWM4NmJkYTRjNzE2ZmFjOTdkZjYyYWQ4MTAhBgorBgEEAYO/MAEUBBMMEXdvcmtmbG93X2Rpc3BhdGNoMIGBBgorBgEEAYO/MAEVBHMMcWh0dHBzOi8vZ2l0aHViLmNvbS9zaWdzdG9yZS1jb25mb3JtYW5jZS9leHRyZW1lbHktZGFuZ2Vyb3VzLXB1YmxpYy1vaWRjLWJlYWNvbi9hY3Rpb25zL3J1bnMvODM0NzQ4MTYyOC9hdHRlbXB0cy8xMBYGCisGAQQBg78wARYECAwGcHVibGljMIGKBgorBgEEAdZ5AgQCBHwEegB4AHYA3T0wasbHETJjGR4cmWc3AqJKXrjePK3/h4pygC8p7o4AAAGOV8AHpgAABAMARzBFAiBFeMbpFarlPwb0naTr4mjWDvXApOd9ORqOk36Brt9SmwIhAJJvjor+DXUXr7S3Vm9jVFT3CL0BxcKGj86m5mYzQvubMAoGCCqGSM49BAMDA2gAMGUCMA8lTixdS4iN9mAUduObcSJmhZLyvK7zaX05DLEDCgPWxDHk+JBZUKYRIuHHgwFnOwIxALMamo9dfENMzRgNCzYfp/y+rSOhVjXXE9mCn6BuJETlpRDfGvxUg/5LF9f4lYqozA=="}, "tlogEntries": [{"logIndex": "79571823", "logId": {"keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="}, "kindVersion": {"kind": "hashedrekord", "version": "0.0.1"}, "integratedTime": "1710869186", "inclusionPromise": {"signedEntryTimestamp": "MEYCIQDMNM49CNrcrpuvB9G3likdSse0miAkY0ILCqzRGP5ZJQIhAKnSS9GUSFVCar1+Sq3qoRtJIJ8x9tqRnQ8kuS1ojtTH"}, "inclusionProof": {"logIndex": "75408392", "rootHash": "Fnnj13Uu1jdksPc4HZLapKX329dVlD5+MGNsiqBq1XM=", "treeSize": "75408393", "hashes": ["1J7hRIEGvYdAyzEs+GhAE9L+38oHye3BhalgoQRZoo4=", "W/OUCkh/lqDDwbBkZgP7eTV/wx4WifD1wtfRLbavfxI=", "9wya2BEhfLGDfDRVN46OU2RXkozWCM1Z4qMu6SPiWoY=", "ZRs3lKAIlu0t0GtLupAcOu1y20nOaOshSKosWAqFO+w=", "BGqH+LzVuhuqCLiUvBJaB2hlsvtu2a15qq1WGw6mG44=", "OeS7D4kPES7ChE7kWSEmhbAMqBcKVj/z8/afMK4Y3pI=", "JtjqvAqFyXXYjWlZfDzElHpEzdBjsz1LmGFJuYx0kTU=", "s/ZIVcfcD4/nuZwUtQf4ydGsIAkGTPTzk3b0zhUC95k=", "YU1jZY/fp5tJdGF/i+/7ez8107O4/lOUp7acMPFEaOA=", "7Z18YLBAvejEV4nJHIKoks/xlijnhR005qTW2w4QtHg=", "98enzMaC+x5oCMvIZQA5z8vu2apDMCFvE/935NfuPw8="], "checkpoint": {"envelope": "rekor.sigstore.dev - 2605736670972794746\n75408393\nFnnj13Uu1jdksPc4HZLapKX329dVlD5+MGNsiqBq1XM=\n\n— rekor.sigstore.dev wNI9ajBFAiBTyiBM9WtyOTgohje6QZ5rFGJUdMq7Wk3A6oThE98SUgIhAMvxDwa7FyqRqg+YV3rdPPrfS23w19iK+piMSGVOmP5w\n"}}, "canonicalizedBody": "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaGFzaGVkcmVrb3JkIiwic3BlYyI6eyJkYXRhIjp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiJhMGNmYzcxMjcxZDZlMjc4ZTU3Y2QzMzJmZjk1N2MzZjcwNDNmZGRhMzU0YzRjYmIxOTBhMzBkNTZlZmEwMWJmIn19LCJzaWduYXR1cmUiOnsiY29udGVudCI6Ik1FVUNJQ1lGcS80YlRFZGx1cmdxVnVObXdDY0lXdTNOS09DZ3ZlV0FKQmllekowdUFpRUEyaTdVMTgrYVJwRnhMWWtzcjVIS0JRUXkwOHpFMDUwV0ljMFJ6S3VuRElBPSIsInB1YmxpY0tleSI6eyJjb250ZW50IjoiTFMwdExTMUNSVWRKVGlCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2sxSlNVbE5WRU5EUWpkbFowRjNTVUpCWjBsVllVd3ZkSE50VVZSSWF6SXhiWFF4VlhWckszYzNZWFpFUW5vMGQwTm5XVWxMYjFwSmVtb3dSVUYzVFhjS1RucEZWazFDVFVkQk1WVkZRMmhOVFdNeWJHNWpNMUoyWTIxVmRWcEhWakpOVWpSM1NFRlpSRlpSVVVSRmVGWjZZVmRrZW1SSE9YbGFVekZ3WW01U2JBcGpiVEZzV2tkc2FHUkhWWGRJYUdOT1RXcFJkMDE2UlRWTlZHTjVUbXBKTWxkb1kwNU5hbEYzVFhwRk5VMVVZM3BPYWtreVYycEJRVTFHYTNkRmQxbElDa3R2V2tsNmFqQkRRVkZaU1V0dldrbDZhakJFUVZGalJGRm5RVVV5TWxNeGFpOU9hMFZZZWtKUVVVRjFZVzFJV0V4d2QzZ3JVbEJ1Ym5wYVVXd3ZjR3NLUlZvNGVHOXlka3R1ZW5WcVExTXhiVlpVUW04NWEwSjRiVmxYYnpKRVNIUjVWbmxtWjI1MVQzRldWSHBNV1cxb2J6WlBRMEowV1hkbloySlRUVUUwUndwQk1WVmtSSGRGUWk5M1VVVkJkMGxJWjBSQlZFSm5UbFpJVTFWRlJFUkJTMEpuWjNKQ1owVkdRbEZqUkVGNlFXUkNaMDVXU0ZFMFJVWm5VVlZHZGpGVENrTjZhVVZMVGpKeVVubHlhbVZXYkVaaVUweG5NUzlSZDBoM1dVUldVakJxUWtKbmQwWnZRVlV6T1ZCd2VqRlphMFZhWWpWeFRtcHdTMFpYYVhocE5Ga0tXa1E0ZDJkaFZVZEJNVlZrUlZGRlFpOTNVMEp0YWtOQ2JEUmhRbXhIYURCa1NFSjZUMms0ZGxveWJEQmhTRlpwVEcxT2RtSlRPWHBoVjJSNlpFYzVlUXBhVXpGcVlqSTFiV0l6U25SWlZ6VnFXbE01YkdWSVVubGFWekZzWWtocmRGcEhSblZhTWxaNVlqTldla3hZUWpGWmJYaHdXWGt4ZG1GWFVtcE1WMHBzQ2xsWFRuWmlhVGgxV2pKc01HRklWbWxNTTJSMlkyMTBiV0pIT1ROamVUbHNaVWhTZVZwWE1XeGlTR3QwV2tkR2RWb3lWbmxpTTFaNlRGYzVjRnBIVFhRS1dXMVdhRmt5T1hWTWJteDBZa1ZDZVZwWFducE1NbWhzV1ZkU2Vrd3lNV2hoVnpSM1QxRlpTMHQzV1VKQ1FVZEVkbnBCUWtGUlVYSmhTRkl3WTBoTk5ncE1lVGt3WWpKMGJHSnBOV2haTTFKd1lqSTFla3h0WkhCa1IyZ3hXVzVXZWxwWVNtcGlNalV3V2xjMU1FeHRUblppVkVGbVFtZHZja0puUlVWQldVOHZDazFCUlVOQ1FrWXpZak5LY2xwdGVIWmtNVGxyWVZoT2QxbFlVbXBoUkVFeVFtZHZja0puUlVWQldVOHZUVUZGUkVKRGFHcE9Na2w2V2tkYWFVMTZUVEVLV21wQk1VMVhWWGhaZW1jeVdXMVNhRTVIVFROTlZGcHRXVmROTlU0eVVtMU9ha3BvV2tSbmVFMURNRWREYVhOSFFWRlJRbWMzT0hkQlVWRkZTREJXTkFwa1NFcHNZbGRXYzJWVFFtdFpWelZ1V2xoS2RtUllUV2RVTUd4RlVYbENhVnBYUm1waU1qUjNVMUZaUzB0M1dVSkNRVWRFZG5wQlFrSlJVVGRqTW14dUNtTXpVblpqYlZWMFdUSTVkVnB0T1hsaVYwWjFXVEpWZGxwWWFEQmpiVlowV2xkNE5VeFhVbWhpYldSc1kyMDVNV041TVhka1YwcHpZVmROZEdJeWJHc0tXWGt4YVZwWFJtcGlNalIzU0ZGWlMwdDNXVUpDUVVkRWRucEJRa0puVVZCamJWWnRZM2s1YjFwWFJtdGplVGwwV1Zkc2RVMUVjMGREYVhOSFFWRlJRZ3BuTnpoM1FWRm5SVXhSZDNKaFNGSXdZMGhOTmt4NU9UQmlNblJzWW1rMWFGa3pVbkJpTWpWNlRHMWtjR1JIYURGWmJsWjZXbGhLYW1JeU5UQmFWelV3Q2t4dFRuWmlWRU5DY0dkWlMwdDNXVUpDUVVkRWRucEJRa05SVTBKc2QzbENiRWRvTUdSSVFucFBhVGgyV2pKc01HRklWbWxNYlU1MllsTTVlbUZYWkhvS1pFYzVlVnBUTVdwaU1qVnRZak5LZEZsWE5XcGFVemxzWlVoU2VWcFhNV3hpU0d0MFdrZEdkVm95Vm5saU0xWjZURmhDTVZsdGVIQlplVEYyWVZkU2FncE1WMHBzV1ZkT2RtSnBPSFZhTW13d1lVaFdhVXd6WkhaamJYUnRZa2M1TTJONU9XeGxTRko1V2xjeGJHSklhM1JhUjBaMVdqSldlV0l6Vm5wTVZ6bHdDbHBIVFhSWmJWWm9XVEk1ZFV4dWJIUmlSVUo1V2xkYWVrd3lhR3haVjFKNlRESXhhR0ZYTkhkUFFWbExTM2RaUWtKQlIwUjJla0ZDUTJkUmNVUkRhR29LVGpKSmVscEhXbWxOZWsweFdtcEJNVTFYVlhoWmVtY3lXVzFTYUU1SFRUTk5WRnB0V1ZkTk5VNHlVbTFPYWtwb1drUm5lRTFDTUVkRGFYTkhRVkZSUWdwbk56aDNRVkZ6UlVSM2QwNWFNbXd3WVVoV2FVeFhhSFpqTTFKc1drUkNaVUpuYjNKQ1owVkZRVmxQTDAxQlJVMUNSa0ZOVkcxb01HUklRbnBQYVRoMkNsb3liREJoU0ZacFRHMU9kbUpUT1hwaFYyUjZaRWM1ZVZwVE1XcGlNalZ0WWpOS2RGbFhOV3BhVXpsc1pVaFNlVnBYTVd4aVNHdDBXa2RHZFZveVZua0tZak5XZWt4WVFqRlpiWGh3V1hreGRtRlhVbXBNVjBwc1dWZE9kbUpxUVRSQ1oyOXlRbWRGUlVGWlR5OU5RVVZPUWtOdlRVdEhUVE5aYWs1cldtMUplZ3BOZWxadFRVUlZlRnBVUm1wUFJGcHBXa2RGTUZsNlkzaE9iVnBvV1hwck0xcEhXVEpOYlVaclQwUkZkMGgzV1V0TGQxbENRa0ZIUkhaNlFVSkVaMUZTQ2tSQk9YbGFWMXA2VERKb2JGbFhVbnBNTWpGb1lWYzBkMGRSV1V0TGQxbENRa0ZIUkhaNlFVSkVkMUZNUkVGck1rMTZTVEZQVkZrMFQxUmpkMDUzV1VzS1MzZFpRa0pCUjBSMmVrRkNSVUZSY0VSRFpHOWtTRkozWTNwdmRrd3laSEJrUjJneFdXazFhbUl5TUhaak1teHVZek5TZG1OdFZYUlpNamwxV20wNWVRcGlWMFoxV1RKVmQwZFJXVXRMZDFsQ1FrRkhSSFo2UVVKRlVWRk1SRUZyZUUxNlJUUk5SRkV4VG1wTmQyZGhXVWREYVhOSFFWRlJRbWMzT0hkQlVrbEZDbWRhWTAxbldsSnZaRWhTZDJONmIzWk1NbVJ3WkVkb01WbHBOV3BpTWpCMll6SnNibU16VW5aamJWVjBXVEk1ZFZwdE9YbGlWMFoxV1RKVmRscFlhREFLWTIxV2RGcFhlRFZNVjFKb1ltMWtiR050T1RGamVURjNaRmRLYzJGWFRYUmlNbXhyV1hreGFWcFhSbXBpTWpSMlRHMWtjR1JIYURGWmFUa3pZak5LY2dwYWJYaDJaRE5OZGxwWWFEQmpiVlowV2xkNE5VeFhVbWhpYldSc1kyMDVNV041TVhaaFYxSnFURmRLYkZsWFRuWmlhVFUxWWxkNFFXTnRWbTFqZVRsdkNscFhSbXRqZVRsMFdWZHNkVTFFWjBkRGFYTkhRVkZSUW1jM09IZEJVazFGUzJkM2IxbDZaR2xOTWxKdFdXcE5lazVYV1hkT1ZFWnNUVmROTkU1dFNtc0tXVlJTYWs1NlJUSmFiVVpxVDFSa2ExcHFXWGxaVjFFMFRWUkJhRUpuYjNKQ1owVkZRVmxQTDAxQlJWVkNRazFOUlZoa2RtTnRkRzFpUnpreldESlNjQXBqTTBKb1pFZE9iMDFKUjBKQ1oyOXlRbWRGUlVGWlR5OU5RVVZXUWtoTlRXTlhhREJrU0VKNlQyazRkbG95YkRCaFNGWnBURzFPZG1KVE9YcGhWMlI2Q21SSE9YbGFVekZxWWpJMWJXSXpTblJaVnpWcVdsTTViR1ZJVW5sYVZ6RnNZa2hyZEZwSFJuVmFNbFo1WWpOV2VreFlRakZaYlhod1dYa3hkbUZYVW1vS1RGZEtiRmxYVG5aaWFUbG9XVE5TY0dJeU5YcE1NMG94WW01TmRrOUVUVEJPZWxFMFRWUlplVTlET1doa1NGSnNZbGhDTUdONU9IaE5RbGxIUTJselJ3cEJVVkZDWnpjNGQwRlNXVVZEUVhkSFkwaFdhV0pIYkdwTlNVZExRbWR2Y2tKblJVVkJaRm8xUVdkUlEwSklkMFZsWjBJMFFVaFpRVE5VTUhkaGMySklDa1ZVU21wSFVqUmpiVmRqTTBGeFNrdFljbXBsVUVzekwyZzBjSGxuUXpod04yODBRVUZCUjA5V09FRkljR2RCUVVKQlRVRlNla0pHUVdsQ1JtVk5ZbkFLUm1GeWJGQjNZakJ1WVZSeU5HMXFWMFIyV0VGd1QyUTVUMUp4VDJzek5rSnlkRGxUYlhkSmFFRktTblpxYjNJclJGaFZXSEkzVXpOV2JUbHFWa1pVTXdwRFREQkNlR05MUjJvNE5tMDFiVmw2VVhaMVlrMUJiMGREUTNGSFUwMDBPVUpCVFVSQk1tZEJUVWRWUTAxQk9HeFVhWGhrVXpScFRqbHRRVlZrZFU5aUNtTlRTbTFvV2t4NWRrczNlbUZZTURWRVRFVkVRMmRRVjNoRVNHc3JTa0phVlV0WlVrbDFTRWhuZDBadVQzZEplRUZNVFdGdGJ6bGtaa1ZPVFhwU1owNEtRM3BaWm5BdmVTdHlVMDlvVm1wWVdFVTViVU51TmtKMVNrVlViSEJTUkdaSGRuaFZaeTgxVEVZNVpqUnNXWEZ2ZWtFOVBRb3RMUzB0TFVWT1JDQkRSVkpVU1VaSlEwRlVSUzB0TFMwdENnPT0ifX19fQ=="}]}, "messageSignature": {"messageDigest": {"algorithm": "SHA2_256", "digest": "oM/HEnHW4njlfNMy/5V8P3BD/do1TEy7GQow1W76Ab8="}, "signature": "MEUCICYFq/4bTEdlurgqVuNmwCcIWu3NKOCgveWAJBiezJ0uAiEA2i7U18+aRpFxLYksr5HKBQQy08zE050WIc0RzKunDIA="}}'''; diff --git a/app/test/service/entrypoint/dockerfile_app_build_test.dart b/app/test/service/entrypoint/dockerfile_app_build_test.dart index edf9cff7d4..706f209afe 100644 --- a/app/test/service/entrypoint/dockerfile_app_build_test.dart +++ b/app/test/service/entrypoint/dockerfile_app_build_test.dart @@ -9,7 +9,13 @@ import 'package:_pub_shared/worker/docker_utils.dart'; import 'package:test/test.dart'; void main() { - test('building Dockerfile.app', () async { + test('building and running Dockerfile.app', () async { await buildAppDockerImage(); - }, timeout: Timeout(Duration(minutes: 5))); + + // The image is only usable when the server can load the native library of + // `package:sigstore` from the snapshot it runs, see + // `tool/build-server-snapshot.sh`. + final output = await runAppDockerImage(['self-check']); + expect(output, contains('self-check: OK')); + }, timeout: Timeout(Duration(minutes: 20))); } diff --git a/pkg/_pub_shared/lib/worker/docker_utils.dart b/pkg/_pub_shared/lib/worker/docker_utils.dart index c1e6464739..bddecef04e 100644 --- a/pkg/_pub_shared/lib/worker/docker_utils.dart +++ b/pkg/_pub_shared/lib/worker/docker_utils.dart @@ -18,6 +18,21 @@ Future buildWorkerDockerImage() async { await _buildDockerImage(path: 'Dockerfile.worker', image: 'pub_worker'); } +/// Runs the `pub_app` image built by [buildAppDockerImage] with [args] passed +/// to its entrypoint (the server), and returns the output of the process. +/// +/// Throws if the process exits with a non-zero exit code. +Future runAppDockerImage(List args) async { + final pr = await Process.run('docker', ['run', '--rm', 'pub_app', ...args]); + if (pr.exitCode != 0) { + throw Exception( + 'docker run exited with code ${pr.exitCode}\n' + 'stdout:\n${pr.stdout}\nstderr:\n${pr.stderr}', + ); + } + return pr.stdout.toString(); +} + Future _buildDockerImage({ required String path, required String image, diff --git a/pubspec.lock b/pubspec.lock index 38e08e761e..c6d640e123 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -209,6 +209,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.3" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" code_builder: dependency: transitive description: @@ -377,6 +385,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f + url: "https://pub.dev" + source: hosted + version: "2.2.0" html: dependency: transitive description: @@ -697,6 +713,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.26.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2" + url: "https://pub.dev" + source: hosted + version: "1.1.1" retry: dependency: transitive description: @@ -769,6 +793,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + sigstore: + dependency: transitive + description: + name: sigstore + sha256: "3f9c596bed64c374f52230e9ce3b167ff1baded695be41f53fadb9b6b5ee6aa3" + url: "https://pub.dev" + source: hosted + version: "0.1.5" slugid: dependency: transitive description: diff --git a/tool/build-server-snapshot.sh b/tool/build-server-snapshot.sh new file mode 100755 index 0000000000..ff4820d7a5 --- /dev/null +++ b/tool/build-server-snapshot.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash + +# 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. + +# Builds the app-JIT snapshot of the server, with the native assets mapping of +# `package:sigstore` embedded. +# +# `package:sigstore` calls into a native library (`libsigstore_ffi.so`) through +# `@Native` bindings, which the Dart VM resolves through a native assets +# mapping (asset id -> library path). `dart run` produces that mapping by +# executing the build hooks of the dependencies and embeds it in the kernel it +# runs; `dart compile jit-snapshot` does not, so a snapshot created by it has +# no way to find the library at runtime. +# +# This script does what `dart run` does, with a mapping that points at the +# location of the library in the production image: +# +# 1. compile `bin/server.dart` to kernel, +# 2. compile the mapping to a kernel component with the frontend server, +# 3. concatenate the two, and +# 4. create the app-JIT snapshot from the result (the VM keeps the mapping in +# the snapshot, no build hooks or sources are needed at runtime). +# +# The library itself is built from the Rust source bundled with the package, +# see `Dockerfile.app`. +# +# Usage: build-server-snapshot.sh + +set -euo pipefail + +if [ "$#" -ne 2 ]; then + echo "Usage: $0 " >&2 + exit 1 +fi +OUTPUT="$1" +SIGSTORE_LIBRARY="$2" + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &> /dev/null && pwd)" +APP_DIR="$SCRIPT_DIR/../app" + +DART_SDK="$(cd -- "$(dirname -- "$(readlink -f "$(command -v dart)")")/.." && pwd)" +DART_AOT_RUNTIME="$DART_SDK/bin/dartaotruntime" +FRONTEND_SERVER="$DART_SDK/bin/snapshots/frontend_server_aot.dart.snapshot" +if [ ! -x "$DART_AOT_RUNTIME" ] || [ ! -f "$FRONTEND_SERVER" ]; then + echo "Could not find the frontend server in the Dart SDK at $DART_SDK" >&2 + exit 1 +fi + +case "$(uname -m)" in + x86_64) DART_ARCH="x64" ;; + aarch64 | arm64) DART_ARCH="arm64" ;; + *) + echo "Unsupported architecture: $(uname -m)" >&2 + exit 1 + ;; +esac + +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "$WORK_DIR"' EXIT + +cd "$APP_DIR" + +# 1. The server as kernel. +dart compile kernel -o "$WORK_DIR/server.dill" bin/server.dart + +# 2. The native assets mapping as kernel component. The asset id is the +# library URI of the `@Native` bindings in `package:sigstore`. +cat > "$WORK_DIR/native_assets.yaml" < "$WORK_DIR/server_with_native_assets.dill" + +# 4. The app-JIT snapshot. The training run is started with `--help` to make it +# exit right away. +dart --snapshot-kind=app-jit --snapshot="$OUTPUT" \ + "$WORK_DIR/server_with_native_assets.dill" --help + +echo "Created $OUTPUT with native assets mapping:" +cat "$WORK_DIR/native_assets.yaml" From 509f358621526ff288a8677ebc6d0efbfdab9221 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 12 Oct 2026 12:57:13 +0000 Subject: [PATCH 2/3] Persist the verified provenance facts with the package version Adds `PackageVersion.provenance` (`VersionProvenance`, a JSON property like `publishingConfig`), set at upload from the verification result: repository (`owner/name`), repository id and owner id (from the signed statement's `internalParameters.github`), workflow path, ref, commit, run URL, the verified signer identity and issuer, and the Rekor log index and integrated time. These are the facts pub.dev verified at upload time, persisted so that the package page and later policy checks (e.g. a lock on the provenance repository, #9694) do not have to parse the bundle again. --- app/lib/package/attestation_verifier.dart | 110 ++++++++++++++++++++- app/lib/package/backend.dart | 14 ++- app/lib/package/models.dart | 111 ++++++++++++++++++++++ app/lib/package/models.g.dart | 34 +++++++ app/test/package/upload_test.dart | 44 +++++++++ 5 files changed, 310 insertions(+), 3 deletions(-) diff --git a/app/lib/package/attestation_verifier.dart b/app/lib/package/attestation_verifier.dart index 4ac2c7baf8..071ecbb939 100644 --- a/app/lib/package/attestation_verifier.dart +++ b/app/lib/package/attestation_verifier.dart @@ -11,6 +11,8 @@ import 'package:meta/meta.dart'; import 'package:pub_semver/pub_semver.dart'; import 'package:sigstore/sigstore.dart'; +import 'models.dart' show VersionProvenance; + export 'package:sigstore/sigstore.dart'; /// The OIDC issuer of tokens minted by GitHub Actions. @@ -194,6 +196,19 @@ class BuildProvenance { /// The commit the build ran at, if recorded. final String? commitSha; + /// The immutable id of [sourceRepository], if recorded. + /// + /// GitHub fills this in from the run context (`internalParameters.github`), + /// it cannot be set by the caller of the workflow. + final String? repositoryId; + + /// The immutable id of the owner of [sourceRepository], if recorded. + final String? repositoryOwnerId; + + /// The URL of the workflow run (`runDetails.metadata.invocationId`), if + /// recorded. + final String? runUrl; + /// The builder as self-reported in the statement. /// /// Note that the authoritative statement about who signed is the certificate @@ -209,6 +224,9 @@ class BuildProvenance { required this.commitSha, required this.builder, required this.subjects, + this.repositoryId, + this.repositoryOwnerId, + this.runUrl, }); /// Extracts the build provenance from the DSSE envelope of the Sigstore @@ -334,11 +352,30 @@ class BuildProvenance { } GitHubWorkflowIdentity? builder; + String? runUrl; final runDetails = predicate['runDetails']; if (runDetails is Map) { final builderField = runDetails['builder']; final id = builderField is Map ? builderField['id'] : null; if (id is String) builder = GitHubWorkflowIdentity.tryParse(id); + final metadata = runDetails['metadata']; + final invocationId = metadata is Map ? metadata['invocationId'] : null; + if (invocationId is String && invocationId.isNotEmpty) { + runUrl = invocationId; + } + } + + String? repositoryId; + String? repositoryOwnerId; + final internalParameters = buildDefinition['internalParameters']; + final github = internalParameters is Map + ? internalParameters['github'] + : null; + if (github is Map) { + final id = github['repository_id']; + if (id is String && id.isNotEmpty) repositoryId = id; + final ownerId = github['repository_owner_id']; + if (ownerId is String && ownerId.isNotEmpty) repositoryOwnerId = ownerId; } final ref = workflow['ref']; @@ -350,6 +387,50 @@ class BuildProvenance { commitSha: commitSha, builder: builder, subjects: subjects, + repositoryId: repositoryId, + repositoryOwnerId: repositoryOwnerId, + runUrl: runUrl, + ); + } +} + +/// The entry of a Sigstore bundle in the transparency log (Rekor). +class TransparencyLogEntry { + final int logIndex; + final DateTime integratedTime; + + TransparencyLogEntry({required this.logIndex, required this.integratedTime}); + + /// Extracts the first transparency log entry from the Sigstore bundle in + /// [bundleJson], or returns `null` if the bundle has none (or is not a + /// bundle at all). + /// + /// Only use the result if `package:sigstore` has verified the very same + /// [bundleJson]: the inclusion proof of the entry is checked there. + static TransparencyLogEntry? tryParseBundle(String bundleJson) { + final Object? decoded; + try { + decoded = json.decode(bundleJson); + } on FormatException { + return null; + } + if (decoded is! Map) return null; + final material = decoded['verificationMaterial']; + final entries = material is Map ? material['tlogEntries'] : null; + if (entries is! List || entries.isEmpty) return null; + final entry = entries.first; + if (entry is! Map) return null; + // Both are int64 values, encoded as strings in the JSON form of the + // bundle protobuf. + final logIndex = int.tryParse('${entry['logIndex']}'); + final integratedTime = int.tryParse('${entry['integratedTime']}'); + if (logIndex == null || integratedTime == null) return null; + return TransparencyLogEntry( + logIndex: logIndex, + integratedTime: DateTime.fromMillisecondsSinceEpoch( + integratedTime * 1000, + isUtc: true, + ), ); } } @@ -368,6 +449,7 @@ class AttestationVerificationResult { final BuildProvenance? provenance; final String? signerIdentity; final String? oidcIssuer; + final TransparencyLogEntry? transparencyLogEntry; final List errors; AttestationVerificationResult({ @@ -376,6 +458,7 @@ class AttestationVerificationResult { this.provenance, this.signerIdentity, this.oidcIssuer, + this.transparencyLogEntry, this.errors = const [], }); @@ -385,7 +468,31 @@ class AttestationVerificationResult { required this.errors, }) : isValid = false, repository = null, - provenance = null; + provenance = null, + transparencyLogEntry = null; + + /// The verified facts to persist with the package version. + /// + /// Throws a [StateError] if the verification was not successful. + VersionProvenance toVersionProvenance() { + if (!isValid) { + throw StateError('The attestation did not verify: $errors'); + } + final provenance = this.provenance!; + return VersionProvenance( + repository: provenance.sourceRepository.slug, + repositoryId: provenance.repositoryId, + repositoryOwnerId: provenance.repositoryOwnerId, + workflowPath: provenance.workflowPath, + ref: provenance.sourceRef, + commitSha: provenance.commitSha, + runUrl: provenance.runUrl, + signer: signerIdentity!, + issuer: oidcIssuer!, + transparencyLogIndex: transparencyLogEntry?.logIndex, + transparencyLogIntegratedTime: transparencyLogEntry?.integratedTime, + ); + } } /// The identity of the signer of an attestation, as verified by Sigstore. @@ -543,6 +650,7 @@ class AttestationVerifier { provenance: provenance, signerIdentity: identity, oidcIssuer: issuer, + transparencyLogEntry: TransparencyLogEntry.tryParseBundle(bundleJson), ); } diff --git a/app/lib/package/backend.dart b/app/lib/package/backend.dart index 94a5c2d5cf..369e76e9cd 100644 --- a/app/lib/package/backend.dart +++ b/app/lib/package/backend.dart @@ -1498,10 +1498,11 @@ class PackageBackend { throw PackageRejectedException.dependencyDoesNotExists(name); } - // Read the attestation, if one was uploaded for this upload. + // Read and verify the attestation, if one was uploaded for this upload. // Note: the content has been validated to be a JSON object when it was // uploaded, see [uploadAttestation]. String? attestationContent; + VersionProvenance? provenance; if (await _incomingBucket.tryInfo(attestationObjectName) != null) { attestationContent = utf8.decode( await _incomingBucket.readAsBytes( @@ -1537,6 +1538,9 @@ class PackageBackend { '${verificationResult.errors.join(', ')}', ); } + // The verified facts are persisted with the version, so that neither + // the package page nor later policy checks need to parse the bundle. + provenance = verificationResult.toVersionProvenance(); _logger.info( 'Attestation verified successfully for ${pubspec.name} $versionString ' 'from repository ${verificationResult.repository}.', @@ -1549,6 +1553,7 @@ class PackageBackend { agent, archive, sha256Hash: sha256Hash, + provenance: provenance, ); final (version, uploadMessages) = await _performTarballUpload( entities: entities, @@ -2846,11 +2851,15 @@ class DerivedPackageVersionEntities { } /// Creates entities from [archive] summary. +/// +/// [provenance] is the verified build provenance of the archive, if it was +/// published with an attestation. Future<_UploadEntities> _createUploadEntities( DatastoreDB db, AuthenticatedAgent agent, PackageSummary archive, { required List sha256Hash, + required VersionProvenance? provenance, }) async { final pubspec = Pubspec.fromYaml(archive.pubspecContent!); final packageKey = db.emptyKey.append(Package, id: pubspec.name); @@ -2865,7 +2874,8 @@ Future<_UploadEntities> _createUploadEntities( ..pubspec = pubspec ..libraries = archive.libraries ..uploader = agent.agentId - ..sha256 = sha256Hash; + ..sha256 = sha256Hash + ..provenance = provenance; final derived = derivePackageVersionEntities( archive: archive, diff --git a/app/lib/package/models.dart b/app/lib/package/models.dart index 3d244062ba..1b5f7db900 100644 --- a/app/lib/package/models.dart +++ b/app/lib/package/models.dart @@ -533,6 +533,110 @@ class GcpPublishingLock { Map toJson() => _$GcpPublishingLockToJson(this); } +/// The build provenance of a package version, as verified at upload from the +/// Sigstore attestation that was published with the archive. +/// +/// These are the facts pub.dev verified at the time of the upload, persisted +/// so that nothing has to re-parse the bundle later (e.g. the package page, +/// or a lock on the provenance repository). The bundle itself is kept next +/// to the archive in the canonical bucket. +@JsonSerializable(explicitToJson: true, includeIfNull: false) +class VersionProvenance { + /// The GitHub repository the archive was built from, as `/`. + final String repository; + + /// The immutable id of [repository] at the time of the build, if the + /// provenance statement carries it. + /// + /// Unlike [repository], this survives renames and transfers, and a deleted + /// and re-created repository gets a new one. + final String? repositoryId; + + /// The immutable id of the owner of [repository] at the time of the build, + /// if the provenance statement carries it. + final String? repositoryOwnerId; + + /// The path of the workflow in [repository] that ran the build, e.g. + /// `.github/workflows/publish.yml`. + final String? workflowPath; + + /// The git ref of [repository] the build ran at, e.g. `refs/tags/v1.0.0`. + final String? ref; + + /// The commit of [repository] the build ran at. + final String? commitSha; + + /// The URL of the workflow run that built the archive, if recorded. + final String? runUrl; + + /// The identity of the trusted workflow that signed the attestation, as + /// verified by Sigstore - the full workflow reference, e.g. + /// `https://github.com/dart-lang/setup-dart/.github/workflows/publish.yml@refs/tags/v2`. + final String signer; + + /// The OIDC issuer that authenticated [signer]. + final String issuer; + + /// The index of the entry in the Sigstore transparency log (Rekor), if the + /// bundle carries one. + final int? transparencyLogIndex; + + /// The time at which the transparency log entry was integrated, if the + /// bundle carries one. + final DateTime? transparencyLogIntegratedTime; + + VersionProvenance({ + required this.repository, + this.repositoryId, + this.repositoryOwnerId, + this.workflowPath, + this.ref, + this.commitSha, + this.runUrl, + required this.signer, + required this.issuer, + this.transparencyLogIndex, + this.transparencyLogIntegratedTime, + }); + + factory VersionProvenance.fromJson(Map json) => + _$VersionProvenanceFromJson(json); + + Map toJson() => _$VersionProvenanceToJson(this); + + /// The URL of [repository]. + String get repositoryUrl => 'https://github.com/$repository'; +} + +/// A [db.Property] encoding [VersionProvenance] as JSON. +class VersionProvenanceProperty extends db.Property { + const VersionProvenanceProperty({super.propertyName, super.required = false}) + : super(indexed: false); + + @override + Object? encodeValue( + db.ModelDB mdb, + Object? value, { + bool forComparison = false, + }) { + if (value == null) return null; + return json.encode(value as VersionProvenance); + } + + @override + Object? decodePrimitiveValue(db.ModelDB mdb, Object? value) { + if (value == null) return null; + return VersionProvenance.fromJson( + json.decode(value as String) as Map, + ); + } + + @override + bool validate(db.ModelDB mdb, Object? value) => + super.validate(mdb, value) && + (value == null || value is VersionProvenance); +} + /// Pub package metadata for a specific uploaded version. /// /// Metadata such as changelog/readme/libraries are used for rendering HTML @@ -577,6 +681,13 @@ class PackageVersion extends db.ExpandoModel { @db.StringProperty() String? publisherId; + /// The build provenance of the archive, as verified from the attestation + /// that was uploaded with it. + /// + /// `null` if the version was published without an attestation. + @VersionProvenanceProperty() + VersionProvenance? provenance; + /// Whether the version has been retracted. @db.BoolProperty(required: true) bool isRetracted = false; diff --git a/app/lib/package/models.g.dart b/app/lib/package/models.g.dart index 754124e2d4..58d7b433a3 100644 --- a/app/lib/package/models.g.dart +++ b/app/lib/package/models.g.dart @@ -90,6 +90,40 @@ GcpPublishingLock _$GcpPublishingLockFromJson(Map json) => Map _$GcpPublishingLockToJson(GcpPublishingLock instance) => {'oauthUserId': instance.oauthUserId}; +VersionProvenance _$VersionProvenanceFromJson(Map json) => + VersionProvenance( + repository: json['repository'] as String, + repositoryId: json['repositoryId'] as String?, + repositoryOwnerId: json['repositoryOwnerId'] as String?, + workflowPath: json['workflowPath'] as String?, + ref: json['ref'] as String?, + commitSha: json['commitSha'] as String?, + runUrl: json['runUrl'] as String?, + signer: json['signer'] as String, + issuer: json['issuer'] as String, + transparencyLogIndex: (json['transparencyLogIndex'] as num?)?.toInt(), + transparencyLogIntegratedTime: + json['transparencyLogIntegratedTime'] == null + ? null + : DateTime.parse(json['transparencyLogIntegratedTime'] as String), + ); + +Map _$VersionProvenanceToJson(VersionProvenance instance) => + { + 'repository': instance.repository, + 'repositoryId': ?instance.repositoryId, + 'repositoryOwnerId': ?instance.repositoryOwnerId, + 'workflowPath': ?instance.workflowPath, + 'ref': ?instance.ref, + 'commitSha': ?instance.commitSha, + 'runUrl': ?instance.runUrl, + 'signer': instance.signer, + 'issuer': instance.issuer, + 'transparencyLogIndex': ?instance.transparencyLogIndex, + 'transparencyLogIntegratedTime': ?instance.transparencyLogIntegratedTime + ?.toIso8601String(), + }; + PackageView _$PackageViewFromJson(Map json) => PackageView( screenshots: (json['screenshots'] as List?) ?.map((e) => ProcessedScreenshot.fromJson(e as Map)) diff --git a/app/test/package/upload_test.dart b/app/test/package/upload_test.dart index a197cac246..b34aeb58a3 100644 --- a/app/test/package/upload_test.dart +++ b/app/test/package/upload_test.dart @@ -1783,6 +1783,26 @@ void main() { ); expect(info!.assets, isNot(contains('attestation'))); + // The verified provenance facts are persisted with the version. + final pv = await packageBackend.lookupPackageVersion( + 'attested_pkg', + '1.0.0', + ); + expect(pv!.provenance?.toJson(), { + 'repository': 'mosuem/attested_pkg', + 'workflowPath': '.github/workflows/publish.yml', + 'ref': 'refs/tags/v1.0.0', + 'commitSha': 'c0ffee7891abbe3dab159e9d0187fc1042d5e0cd', + 'signer': + 'https://github.com/dart-lang/setup-dart/.github/workflows/publish.yml@refs/tags/v2', + 'issuer': 'https://token.actions.githubusercontent.com', + }); + final unattested = await packageBackend.lookupPackageVersion( + 'attested_pkg', + '0.9.0', + ); + expect(unattested!.provenance, isNull); + // The attestation is exported, to be served from the bucket. final exported = await packageBackend.getExportedAttestationInfo( 'attested_pkg', @@ -2038,6 +2058,30 @@ void main() { '63216a3022ed606538399634cd1c525f8bd4657f', ); + // The facts that get persisted with the version. + final persisted = validResult.toVersionProvenance(); + expect(persisted.toJson(), { + 'repository': 'mosuem/helpful', + 'repositoryId': '1339313025', + 'repositoryOwnerId': '27859082', + 'workflowPath': '.github/workflows/publish.yaml', + 'ref': 'refs/heads/main', + 'commitSha': '63216a3022ed606538399634cd1c525f8bd4657f', + 'runUrl': + 'https://github.com/mosuem/helpful/actions/runs/33514943555/attempts/1', + 'signer': + 'https://github.com/dart-lang/setup-dart/.github/workflows/publish.yml@refs/heads/publish-package-attestations', + 'issuer': 'https://token.actions.githubusercontent.com', + 'transparencyLogIndex': 2676401329, + 'transparencyLogIntegratedTime': '2026-09-01T13:42:37.000Z', + }); + expect(persisted.repositoryUrl, 'https://github.com/mosuem/helpful'); + // ... and they survive the JSON round-trip of the Datastore property. + expect( + VersionProvenance.fromJson(persisted.toJson()).toJson(), + persisted.toJson(), + ); + final wrongPubspecRepo = verifier.verify( packageName: 'helpful', packageVersion: Version(0, 1, 5), From a9af66932d63c09da54316a7df2279e3668cd81f Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 23 Oct 2026 09:06:44 +0000 Subject: [PATCH 3/3] Use the provenance parsing and policy from package:sigstore/provenance.dart Replaces the copies of GitHubRepository, GitHubWorkflowIdentity, BuildProvenance, TransparencyLogEntry and checkProvenancePolicy in attestation_verifier.dart with package:sigstore/provenance.dart (dart-lang/tools#2710). The policy takes the version as a string there and the trusted signer workflows explicitly; defaultTrustedSignerWorkflows stays here. This moves from sigstore 0.1.x to 0.2.x, which routes its cryptography through package:boring, so the server needs a second native library: tool/build-server-snapshot.sh embeds the mapping for libbssl_dart.so as well, and Dockerfile.app builds it from the CMake project bundled with the package in the (renamed) native-build stage. Until package:sigstore 0.2.5 is published from dart-lang/tools, this is a git dependency pinned to the commit of dart-lang/tools#2710. Note that tool/pub_get_offline.dart only pre-populates hosted packages, so the Docker image cannot be built until it is the hosted dependency again. --- Dockerfile.app | 39 +- app/lib/package/attestation_verifier.dart | 564 +----------------- app/pubspec.yaml | 8 +- app/test/package/upload_test.dart | 2 +- .../entrypoint/dockerfile_app_build_test.dart | 6 +- pubspec.lock | 65 +- tool/build-server-snapshot.sh | 36 +- 7 files changed, 129 insertions(+), 591 deletions(-) diff --git a/Dockerfile.app b/Dockerfile.app index a5b810c7ae..b18fa0453f 100644 --- a/Dockerfile.app +++ b/Dockerfile.app @@ -42,18 +42,26 @@ RUN dart /project/tool/pub_get_offline.dart /project/app RUN /project/tool/download-sdk-index-jsons.sh # Generate the JIT snapshot, with the native assets mapping of package:sigstore -# embedded (see tool/build-server-snapshot.sh). The library is built in the -# rust-build stage below and copied to the given path in the final image. -RUN /project/tool/build-server-snapshot.sh /project/app/server.jit /project/app/libsigstore_ffi.so +# and package:boring embedded (see tool/build-server-snapshot.sh). The libraries +# are built in the native-build stage below and copied to the given paths in +# the final image. +RUN /project/tool/build-server-snapshot.sh /project/app/server.jit \ + /project/app/libsigstore_ffi.so /project/app/libbssl_dart.so -# Collect what the rust-build and final stages need from this one: +# Collect what the native-build and final stages need from this one: # - the Rust source of package:sigstore's native library, as resolved by pub, +# - the CMake project and vendored BoringSSL of package:boring, as resolved +# by pub, # - libgcc_s, which the Rust library depends on and which is not part of the # minimal /runtime of the Dart image. RUN set -eux; \ SIGSTORE_DIRS="$(ls -d /project/.pub-cache/hosted/pub.dev/sigstore-*/)"; \ [ "$(echo "$SIGSTORE_DIRS" | wc -l)" -eq 1 ]; \ cp -r "${SIGSTORE_DIRS}rust" /project/sigstore-rust; \ + BORING_DIRS="$(ls -d /project/.pub-cache/hosted/pub.dev/boring-*/)"; \ + [ "$(echo "$BORING_DIRS" | wc -l)" -eq 1 ]; \ + mkdir -p /project/boring; \ + cp -r "${BORING_DIRS}src" "${BORING_DIRS}third_party" /project/boring/; \ case "$(dpkg --print-architecture)" in \ amd64) TRIPLET="x86_64-linux-gnu" ;; \ arm64) TRIPLET="aarch64-linux-gnu" ;; \ @@ -69,9 +77,15 @@ RUN mkdir -p /project/tmp && chmod 1777 /project/tmp RUN dart compile kernel -o search_index.dill lib/service/entrypoint/search_index.dart RUN dart compile kernel -o sdk_isolate_index.dill lib/service/entrypoint/sdk_isolate_index.dart -# Stage: Build the native library of package:sigstore from the Rust source -# bundled with the package (no prebuilt binaries are downloaded). -FROM mirror.gcr.io/library/rust:1.99-bookworm AS rust-build +# Stage: Build the native libraries of package:sigstore and package:boring from +# the sources bundled with the packages (no prebuilt binaries are downloaded): +# - libsigstore_ffi.so from the Rust crate of package:sigstore, which calls +# BoringSSL through function pointers it receives from Dart at startup, so +# it does not link against it; +# - libbssl_dart.so from the CMake project of package:boring, the same build +# its `checkout` build mode runs (hook/build.dart), which installs the +# dynamic (and static) library into the install prefix. +FROM mirror.gcr.io/library/rust:1.99-bookworm AS native-build RUN apt-get update && \ apt-get install -y --no-install-recommends cmake && \ rm -rf /var/lib/apt/lists/* @@ -79,6 +93,12 @@ COPY --from=build /project/sigstore-rust /project/sigstore-rust WORKDIR /project/sigstore-rust RUN cargo rustc --release --locked --crate-type=cdylib && \ test -f target/release/libsigstore_ffi.so +COPY --from=build /project/boring /project/boring +WORKDIR /project/boring +RUN cmake -S src -B build -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX=/project/boring/install && \ + cmake --build build --target install --parallel && \ + test -f install/libbssl_dart.so # Build minimal serving image. FROM scratch @@ -96,8 +116,9 @@ COPY --from=build /project/app/lib/frontend/templates /project/app/lib/frontend/ COPY --from=build /project/app/.dart_tool/pub-search-data /project/app/.dart_tool/pub-search-data COPY --from=build /project/.dart_tool/package_config.json /project/.dart_tool/package_config.json COPY --from=go-build /project/pkg/signature_verifier/signature_verifier /project/app/signature_verifier -# Must match the path given to tool/build-server-snapshot.sh above. -COPY --from=rust-build /project/sigstore-rust/target/release/libsigstore_ffi.so /project/app/libsigstore_ffi.so +# Must match the paths given to tool/build-server-snapshot.sh above. +COPY --from=native-build /project/sigstore-rust/target/release/libsigstore_ffi.so /project/app/libsigstore_ffi.so +COPY --from=native-build /project/boring/install/libbssl_dart.so /project/app/libbssl_dart.so # Put the kernel snapshots at the same place as the source files for Isolate.spawnUri to work transparently. COPY --from=build /project/app/search_index.dill /project/app/search_index.dill COPY --from=build /project/app/sdk_isolate_index.dill /project/app/sdk_isolate_index.dill diff --git a/app/lib/package/attestation_verifier.dart b/app/lib/package/attestation_verifier.dart index 071ecbb939..c1924de416 100644 --- a/app/lib/package/attestation_verifier.dart +++ b/app/lib/package/attestation_verifier.dart @@ -2,26 +2,18 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -import 'dart:convert'; - -import 'package:collection/collection.dart'; import 'package:crypto/crypto.dart'; import 'package:gcloud/service_scope.dart' as ss; import 'package:meta/meta.dart'; import 'package:pub_semver/pub_semver.dart'; +import 'package:sigstore/provenance.dart'; import 'package:sigstore/sigstore.dart'; import 'models.dart' show VersionProvenance; +export 'package:sigstore/provenance.dart'; export 'package:sigstore/sigstore.dart'; -/// The OIDC issuer of tokens minted by GitHub Actions. -const githubActionsOidcIssuer = 'https://token.actions.githubusercontent.com'; - -const _inTotoStatementType = 'https://in-toto.io/Statement/v1'; -const _inTotoPayloadType = 'application/vnd.in-toto+json'; -const _slsaProvenancePredicateType = 'https://slsa.dev/provenance/v1'; - /// The workflows trusted to sign attestations for packages on pub.dev, as /// `//`. /// @@ -35,413 +27,6 @@ const defaultTrustedSignerWorkflows = { 'dart-lang/setup-dart/.github/workflows/publish.yml', }; -/// A GitHub repository, identified by its [owner] and [name]. -/// -/// GitHub treats owner and repository names case-insensitively, and so does -/// [operator ==]. -class GitHubRepository { - final String owner; - final String name; - - GitHubRepository(this.owner, this.name); - - /// Parses a reference to a GitHub repository URL, or returns `null` if - /// [source] does not denote one. - /// - /// Accepts the forms used in `pubspec.yaml`'s `repository:` field and in - /// SLSA provenance statements: - /// - /// * `https://github.com//` - /// * `https://github.com//.git` - /// * `git+https://github.com//@` - /// * `https://github.com///tree//` (monorepos) - static GitHubRepository? tryParse(String source) { - var s = source.trim(); - if (s.isEmpty) return null; - if (s.startsWith('git+')) { - s = s.substring('git+'.length); - final at = s.lastIndexOf('@'); - if (at > 0) s = s.substring(0, at); - } - final uri = Uri.tryParse(s); - if (uri == null) return null; - if (uri.scheme != 'http' && uri.scheme != 'https') return null; - final host = uri.host.toLowerCase(); - if (host != 'github.com' && host != 'www.github.com') return null; - final segments = uri.pathSegments.where((s) => s.isNotEmpty).toList(); - if (segments.length < 2) return null; - final owner = segments[0]; - var name = segments[1]; - if (name.endsWith('.git')) { - name = name.substring(0, name.length - '.git'.length); - } - if (owner.isEmpty || name.isEmpty) return null; - return GitHubRepository(owner, name); - } - - /// Parses an `/` slug (as stored in - /// `GitHubPublishingConfig.repository`) or a GitHub repository URL. - static GitHubRepository? tryParseSlug(String source) { - final fromUrl = tryParse(source); - if (fromUrl != null) return fromUrl; - final trimmed = source.trim(); - if (trimmed.isEmpty || trimmed.contains(':')) return null; - final parts = trimmed.split('/'); - if (parts.length != 2) return null; - final owner = parts[0]; - var name = parts[1]; - if (name.endsWith('.git')) { - name = name.substring(0, name.length - '.git'.length); - } - if (owner.isEmpty || name.isEmpty) return null; - return GitHubRepository(owner, name); - } - - /// `/`, as used by the GitHub API and in OIDC claims. - String get slug => '$owner/$name'; - - /// The canonical url of this repository. - String get url => 'https://github.com/$owner/$name'; - - @override - bool operator ==(Object other) => - other is GitHubRepository && - owner.toLowerCase() == other.owner.toLowerCase() && - name.toLowerCase() == other.name.toLowerCase(); - - @override - int get hashCode => Object.hash(owner.toLowerCase(), name.toLowerCase()); - - @override - String toString() => url; -} - -/// The identity of the workflow that signed an attestation. -/// -/// This is the `job_workflow_ref` claim of the GitHub Actions OIDC token, -/// which Fulcio puts in the subject alternative name of the signing -/// certificate. For a reusable workflow it identifies the *reusable* workflow -/// - the trusted builder - and not the repository that called it. -class GitHubWorkflowIdentity { - final GitHubRepository repository; - - /// The path of the workflow file within [repository], e.g. - /// `.github/workflows/publish.yml`. - final String path; - - /// The git ref the workflow ran at, e.g. `refs/tags/v2`. - final String ref; - - GitHubWorkflowIdentity({ - required this.repository, - required this.path, - required this.ref, - }); - - /// Parses an identity of the form - /// `https://github.com///@`. - static GitHubWorkflowIdentity? tryParse(String identity) { - final at = identity.lastIndexOf('@'); - if (at <= 0) return null; - final url = identity.substring(0, at); - final ref = identity.substring(at + 1); - if (ref.isEmpty) return null; - final repository = GitHubRepository.tryParse(url); - if (repository == null) return null; - final uri = Uri.tryParse(url); - if (uri == null) return null; - final segments = uri.pathSegments.where((s) => s.isNotEmpty).toList(); - if (segments.length < 3) return null; - return GitHubWorkflowIdentity( - repository: repository, - path: segments.sublist(2).join('/'), - ref: ref, - ); - } - - /// `//` - the workflow without the ref it ran at. - String get workflow => '${repository.slug}/$path'; - - @override - String toString() => '${repository.url}/$path@$ref'; -} - -/// An entry of the `subject` list of an in-toto statement. -class ProvenanceSubject { - final String name; - - /// The lowercase hex-encoded sha256 digest, or `null` if the subject is not - /// identified by a sha256. - final String? sha256; - - ProvenanceSubject(this.name, this.sha256); -} - -/// The SLSA build provenance of an artifact, as carried in the DSSE envelope -/// of a Sigstore bundle. -class BuildProvenance { - /// The repository the build ran for. - /// - /// This is `github.repository` of the workflow run - for a reusable - /// workflow, the repository that *called* the trusted builder. It is filled - /// in by GitHub from the run context and cannot be set by the caller. - final GitHubRepository sourceRepository; - - /// The ref of [sourceRepository] the build ran at, e.g. `refs/tags/v1.0.0`. - final String? sourceRef; - - /// The path of the caller's workflow file. - final String? workflowPath; - - /// The commit the build ran at, if recorded. - final String? commitSha; - - /// The immutable id of [sourceRepository], if recorded. - /// - /// GitHub fills this in from the run context (`internalParameters.github`), - /// it cannot be set by the caller of the workflow. - final String? repositoryId; - - /// The immutable id of the owner of [sourceRepository], if recorded. - final String? repositoryOwnerId; - - /// The URL of the workflow run (`runDetails.metadata.invocationId`), if - /// recorded. - final String? runUrl; - - /// The builder as self-reported in the statement. - /// - /// Note that the authoritative statement about who signed is the certificate - /// identity, not this field. - final GitHubWorkflowIdentity? builder; - - final List subjects; - - BuildProvenance({ - required this.sourceRepository, - required this.sourceRef, - required this.workflowPath, - required this.commitSha, - required this.builder, - required this.subjects, - this.repositoryId, - this.repositoryOwnerId, - this.runUrl, - }); - - /// Extracts the build provenance from the DSSE envelope of the Sigstore - /// bundle in [bundleJson]. - /// - /// Throws a [FormatException] if [bundleJson] is not a bundle carrying a - /// SLSA build provenance statement. - /// - /// This does not validate any signature. Only use the result if - /// `package:sigstore` has verified the very same [bundleJson]. - factory BuildProvenance.parseBundle(String bundleJson) { - final Object? decoded; - try { - decoded = json.decode(bundleJson); - } on FormatException catch (e) { - throw FormatException('Attestation is not valid JSON: ${e.message}'); - } - final bundle = _expectMap(decoded, 'bundle'); - final envelope = bundle['dsseEnvelope']; - if (envelope == null) { - throw const FormatException( - 'Attestation does not contain a `dsseEnvelope`, so it carries no ' - 'build provenance.', - ); - } - final dsse = _expectMap(envelope, 'dsseEnvelope'); - final payloadType = dsse['payloadType']; - if (payloadType != _inTotoPayloadType) { - throw FormatException( - 'Attestation payload has type `$payloadType`, ' - 'expected `$_inTotoPayloadType`.', - ); - } - final encodedPayload = dsse['payload']; - if (encodedPayload is! String) { - throw const FormatException('Attestation has no `payload`.'); - } - final Object? decodedStatement; - try { - decodedStatement = json.decode( - utf8.decode(base64.decode(encodedPayload)), - ); - } on FormatException catch (e) { - throw FormatException( - 'Attestation payload is not valid base64-encoded JSON: ${e.message}', - ); - } - final statement = _expectMap(decodedStatement, 'statement'); - - final type = statement['_type']; - if (type != _inTotoStatementType) { - throw FormatException( - 'Attestation statement has type `$type`, ' - 'expected `$_inTotoStatementType`.', - ); - } - final predicateType = statement['predicateType']; - if (predicateType != _slsaProvenancePredicateType) { - throw FormatException( - 'Attestation has predicate type `$predicateType`, ' - 'expected `$_slsaProvenancePredicateType`.', - ); - } - - final subjects = []; - final subjectList = statement['subject']; - if (subjectList is! List || subjectList.isEmpty) { - throw const FormatException('Attestation statement has no `subject`.'); - } - for (final subject in subjectList) { - final s = _expectMap(subject, 'subject'); - final name = s['name']; - if (name is! String) { - throw const FormatException('Attestation subject has no `name`.'); - } - final digest = s['digest']; - final sha256 = digest is Map ? digest['sha256'] : null; - subjects.add( - ProvenanceSubject(name, sha256 is String ? sha256.toLowerCase() : null), - ); - } - - final predicate = _expectMap(statement['predicate'], 'predicate'); - final buildDefinition = _expectMap( - predicate['buildDefinition'], - 'buildDefinition', - ); - final externalParameters = _expectMap( - buildDefinition['externalParameters'], - 'externalParameters', - ); - final workflow = _expectMap(externalParameters['workflow'], 'workflow'); - - final repositoryField = workflow['repository']; - if (repositoryField is! String) { - throw const FormatException( - 'Attestation does not state the repository it was built from.', - ); - } - final sourceRepository = GitHubRepository.tryParse(repositoryField); - if (sourceRepository == null) { - throw FormatException( - 'Attestation was built from `$repositoryField`, which is not a GitHub ' - 'repository. Only GitHub is supported for now.', - ); - } - - String? commitSha; - final resolvedDependencies = buildDefinition['resolvedDependencies']; - if (resolvedDependencies is List) { - for (final dependency in resolvedDependencies) { - if (dependency is! Map) continue; - final uri = dependency['uri']; - if (uri is! String) continue; - if (GitHubRepository.tryParse(uri) != sourceRepository) continue; - final digest = dependency['digest']; - final gitCommit = digest is Map ? digest['gitCommit'] : null; - if (gitCommit is String) { - commitSha = gitCommit; - break; - } - } - } - - GitHubWorkflowIdentity? builder; - String? runUrl; - final runDetails = predicate['runDetails']; - if (runDetails is Map) { - final builderField = runDetails['builder']; - final id = builderField is Map ? builderField['id'] : null; - if (id is String) builder = GitHubWorkflowIdentity.tryParse(id); - final metadata = runDetails['metadata']; - final invocationId = metadata is Map ? metadata['invocationId'] : null; - if (invocationId is String && invocationId.isNotEmpty) { - runUrl = invocationId; - } - } - - String? repositoryId; - String? repositoryOwnerId; - final internalParameters = buildDefinition['internalParameters']; - final github = internalParameters is Map - ? internalParameters['github'] - : null; - if (github is Map) { - final id = github['repository_id']; - if (id is String && id.isNotEmpty) repositoryId = id; - final ownerId = github['repository_owner_id']; - if (ownerId is String && ownerId.isNotEmpty) repositoryOwnerId = ownerId; - } - - final ref = workflow['ref']; - final path = workflow['path']; - return BuildProvenance( - sourceRepository: sourceRepository, - sourceRef: ref is String ? ref : null, - workflowPath: path is String ? path : null, - commitSha: commitSha, - builder: builder, - subjects: subjects, - repositoryId: repositoryId, - repositoryOwnerId: repositoryOwnerId, - runUrl: runUrl, - ); - } -} - -/// The entry of a Sigstore bundle in the transparency log (Rekor). -class TransparencyLogEntry { - final int logIndex; - final DateTime integratedTime; - - TransparencyLogEntry({required this.logIndex, required this.integratedTime}); - - /// Extracts the first transparency log entry from the Sigstore bundle in - /// [bundleJson], or returns `null` if the bundle has none (or is not a - /// bundle at all). - /// - /// Only use the result if `package:sigstore` has verified the very same - /// [bundleJson]: the inclusion proof of the entry is checked there. - static TransparencyLogEntry? tryParseBundle(String bundleJson) { - final Object? decoded; - try { - decoded = json.decode(bundleJson); - } on FormatException { - return null; - } - if (decoded is! Map) return null; - final material = decoded['verificationMaterial']; - final entries = material is Map ? material['tlogEntries'] : null; - if (entries is! List || entries.isEmpty) return null; - final entry = entries.first; - if (entry is! Map) return null; - // Both are int64 values, encoded as strings in the JSON form of the - // bundle protobuf. - final logIndex = int.tryParse('${entry['logIndex']}'); - final integratedTime = int.tryParse('${entry['integratedTime']}'); - if (logIndex == null || integratedTime == null) return null; - return TransparencyLogEntry( - logIndex: logIndex, - integratedTime: DateTime.fromMillisecondsSinceEpoch( - integratedTime * 1000, - isUtc: true, - ), - ); - } -} - -Map _expectMap(Object? value, String what) { - if (value is! Map) { - throw FormatException('Attestation `$what` is not a JSON object.'); - } - return value; -} - /// Result of verifying a package attestation bundle. class AttestationVerificationResult { final bool isValid; @@ -533,8 +118,9 @@ class AttestationVerifier { _trustedSignerWorkflows = trustedSignerWorkflows, _requireTaggedSignerRef = requireTaggedSignerRef; - /// Checks that the native library of `package:sigstore` can be loaded and - /// called. + /// Checks that the native libraries of `package:sigstore` and of + /// `package:boring`, which it routes its cryptography through, can be loaded + /// and called. /// /// Throws (an [ArgumentError] from `dart:ffi`) when the `@Native` bindings /// cannot be resolved - e.g. when the native assets mapping is missing from @@ -625,15 +211,15 @@ class AttestationVerifier { final errors = checkProvenancePolicy( packageName: packageName, - packageVersion: packageVersion, + packageVersion: packageVersion.toString(), archiveSha256: Digest(archiveSha256).toString(), provenance: provenance, signer: signer, + trustedSignerWorkflows: _trustedSignerWorkflows, oidcIssuer: issuer, - pubspecRepository: pubspecRepository, - configuredGitHubRepository: configuredGitHubRepository, + expectedRepository: pubspecRepository, + configuredRepository: configuredGitHubRepository, requireRepository: requireRepository, - trustedSignerWorkflows: _trustedSignerWorkflows, requireTaggedSignerRef: _requireTaggedSignerRef, ); if (errors.isNotEmpty) { @@ -699,135 +285,3 @@ class AttestationVerifier { ); } } - -/// Checks a cryptographically verified attestation against pub.dev's policy, -/// and returns the list of violations - empty if the attestation is acceptable. -List checkProvenancePolicy({ - required String packageName, - required Version packageVersion, - required String archiveSha256, - required BuildProvenance provenance, - required GitHubWorkflowIdentity signer, - String? oidcIssuer, - String? pubspecRepository, - String? configuredGitHubRepository, - bool requireRepository = true, - Set trustedSignerWorkflows = defaultTrustedSignerWorkflows, - bool requireTaggedSignerRef = false, -}) { - final errors = []; - - if (oidcIssuer != null && oidcIssuer != githubActionsOidcIssuer) { - errors.add( - 'Attestation was issued by "$oidcIssuer", ' - 'expected "$githubActionsOidcIssuer".', - ); - } - - // Who signed? Only a small set of workflows may sign packages for pub.dev. - final trusted = trustedSignerWorkflows.map((w) => w.toLowerCase()).toSet(); - if (!trusted.contains(signer.workflow.toLowerCase())) { - errors.add( - 'Attestation was signed by "${signer.workflow}", which is not a ' - 'workflow trusted to publish packages. Trusted workflows: ' - '${(trustedSignerWorkflows.toList()..sort()).join(', ')}.', - ); - } else if (requireTaggedSignerRef && !signer.ref.startsWith('refs/tags/')) { - errors.add( - 'Attestation was signed by "${signer.workflow}" at "${signer.ref}", ' - 'but only tagged releases of the publishing workflow are trusted.', - ); - } else if (!signer.ref.startsWith('refs/tags/') && - !signer.ref.startsWith('refs/heads/')) { - errors.add( - 'Attestation was signed by "${signer.workflow}" at "${signer.ref}", ' - 'which is not a branch or tag ref.', - ); - } - final builder = provenance.builder; - if (builder != null && - builder.workflow.toLowerCase() != signer.workflow.toLowerCase()) { - errors.add( - 'Attestation states it was built by "${builder.workflow}" but was ' - 'signed by "${signer.workflow}".', - ); - } - - // What was signed? - // - // The subject name does not bind the package name or version: the - // `dart-lang/setup-dart` publishing workflow signs the archive as - // `package.tar.gz`. The binding is the digest - [packageName] and - // [packageVersion] were read from the `pubspec.yaml` inside the archive - // whose sha256 is [archiveSha256], so matching the digest ties the statement - // to exactly that package version. - const genericSubject = 'package.tar.gz'; - final expectedSubject = '$packageName-$packageVersion.tar.gz'; - final subject = provenance.subjects - .where((s) => s.name == expectedSubject || s.name == genericSubject) - .firstOrNull; - if (subject == null) { - errors.add( - 'Attestation is for ' - '${provenance.subjects.map((s) => '"${s.name}"').join(', ')}, ' - 'not for "$expectedSubject" or "$genericSubject".', - ); - } else if (subject.sha256 != archiveSha256.toLowerCase()) { - errors.add( - 'Attestation is for an archive with sha256 "${subject.sha256}", ' - 'but the uploaded archive has sha256 "$archiveSha256".', - ); - } - - // Where was it built? Verify against the pubspec.yaml `repository` inside the - // uploaded tarball. - if (pubspecRepository == null || pubspecRepository.trim().isEmpty) { - if (requireRepository) { - errors.add( - 'The pubspec.yaml of $packageName $packageVersion does not have a ' - '`repository` field, so the attestation cannot be bound to the ' - 'repository it claims to come from ' - '("${provenance.sourceRepository.url}").', - ); - } - } else { - final expected = GitHubRepository.tryParse(pubspecRepository); - if (expected == null) { - errors.add( - 'Package attestation verification is currently only supported for ' - 'GitHub repositories, but the pubspec.yaml of $packageName ' - '$packageVersion declares `repository: $pubspecRepository`.', - ); - } else if (expected != provenance.sourceRepository) { - errors.add( - 'Attestation states the package was built from ' - '"${provenance.sourceRepository.url}", but the pubspec.yaml of ' - '$packageName $packageVersion declares `repository: ' - '$pubspecRepository`.', - ); - } - } - - // When GitHub Actions automated publishing is configured on pub.dev for this - // package, also verify that the provenance source repository matches the - // stored automated publishing repository configuration (and not just the - // self-asserted `repository:` field inside the uploaded tarball's - // pubspec.yaml). - if (configuredGitHubRepository != null && - configuredGitHubRepository.trim().isNotEmpty) { - final configuredRepo = GitHubRepository.tryParseSlug( - configuredGitHubRepository, - ); - if (configuredRepo == null || - configuredRepo != provenance.sourceRepository) { - errors.add( - 'Attestation states the package was built from ' - '"${provenance.sourceRepository.url}", which does not match the ' - 'configured GitHub Actions automated publishing repository ' - '"$configuredGitHubRepository".', - ); - } - } - - return errors; -} diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 18c844142f..e5e8473cb7 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -56,7 +56,13 @@ dependencies: ulid: '2.2.0' tar: '2.0.2' api_builder: - sigstore: ^0.1.0 + # TODO(https://github.com/dart-lang/tools/pull/2710): Depend on the hosted + # `^0.2.5` once `package:sigstore/provenance.dart` is published. + sigstore: + git: + url: https://github.com/dart-lang/tools.git + ref: 233e5ab23f23ec1ff52f79a39efb77db74d5c73d + path: pkgs/sigstore dev_dependencies: build_runner: '^2.0.0' diff --git a/app/test/package/upload_test.dart b/app/test/package/upload_test.dart index b34aeb58a3..59e50ebb6f 100644 --- a/app/test/package/upload_test.dart +++ b/app/test/package/upload_test.dart @@ -2033,7 +2033,7 @@ void main() { status: 400, code: 'PackageRejected', message: - 'Attestation is for an archive with sha256 "$otherSha256", but the uploaded archive has sha256 "${sha256.convert(archiveBytes)}".', + 'Attestation is for an archive with sha256 "$otherSha256", but this archive has sha256 "${sha256.convert(archiveBytes)}".', ); }, ); diff --git a/app/test/service/entrypoint/dockerfile_app_build_test.dart b/app/test/service/entrypoint/dockerfile_app_build_test.dart index 706f209afe..c04a83cb2b 100644 --- a/app/test/service/entrypoint/dockerfile_app_build_test.dart +++ b/app/test/service/entrypoint/dockerfile_app_build_test.dart @@ -12,9 +12,9 @@ void main() { test('building and running Dockerfile.app', () async { await buildAppDockerImage(); - // The image is only usable when the server can load the native library of - // `package:sigstore` from the snapshot it runs, see - // `tool/build-server-snapshot.sh`. + // The image is only usable when the server can load the native libraries + // of `package:sigstore` and `package:boring` from the snapshot it runs, + // see `tool/build-server-snapshot.sh`. final output = await runAppDockerImage(['self-check']); expect(output, contains('self-check: OK')); }, timeout: Timeout(Duration(minutes: 20))); diff --git a/pubspec.lock b/pubspec.lock index c6d640e123..ad6a6630f8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -73,6 +73,14 @@ 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" buffer: dependency: transitive description: @@ -137,6 +145,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.13.0" + change_case: + dependency: transitive + description: + name: change_case + sha256: e41ef3df58521194ef8d7649928954805aeb08061917cf658322305e61568003 + url: "https://pub.dev" + source: hosted + version: "2.2.0" characters: dependency: transitive description: @@ -213,10 +229,10 @@ packages: dependency: transitive description: name: code_assets - sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + sha256: "828110d598123b5ea96c00c9f3c72105bf79f8ee36c20a39b26209ade421ec57" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "2.1.0" code_builder: dependency: transitive description: @@ -577,6 +593,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.1" + 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" neat_cache: dependency: transitive description: @@ -657,6 +689,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.1.0" + platform: + dependency: transitive + description: + name: platform + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec + url: "https://pub.dev" + source: hosted + version: "3.2.0" pool: dependency: transitive description: @@ -681,6 +721,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.5.20" + process: + dependency: transitive + description: + name: process + sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891" + url: "https://pub.dev" + source: hosted + version: "5.0.6" protobuf: dependency: transitive description: @@ -796,11 +844,12 @@ packages: sigstore: dependency: transitive description: - name: sigstore - sha256: "3f9c596bed64c374f52230e9ce3b167ff1baded695be41f53fadb9b6b5ee6aa3" - url: "https://pub.dev" - source: hosted - version: "0.1.5" + path: "pkgs/sigstore" + ref: "233e5ab23f23ec1ff52f79a39efb77db74d5c73d" + resolved-ref: "233e5ab23f23ec1ff52f79a39efb77db74d5c73d" + url: "https://github.com/dart-lang/tools.git" + source: git + version: "0.2.5-wip" slugid: dependency: transitive description: @@ -1042,4 +1091,4 @@ packages: source: hosted version: "2.2.4" sdks: - dart: ">=3.13.0 <4.0.0" + dart: ">=3.13.3 <4.0.0" diff --git a/tool/build-server-snapshot.sh b/tool/build-server-snapshot.sh index ff4820d7a5..73392f550d 100755 --- a/tool/build-server-snapshot.sh +++ b/tool/build-server-snapshot.sh @@ -5,14 +5,16 @@ # BSD-style license that can be found in the LICENSE file. # Builds the app-JIT snapshot of the server, with the native assets mapping of -# `package:sigstore` embedded. +# `package:sigstore` and `package:boring` embedded. # # `package:sigstore` calls into a native library (`libsigstore_ffi.so`) through -# `@Native` bindings, which the Dart VM resolves through a native assets -# mapping (asset id -> library path). `dart run` produces that mapping by -# executing the build hooks of the dependencies and embeds it in the kernel it -# runs; `dart compile jit-snapshot` does not, so a snapshot created by it has -# no way to find the library at runtime. +# `@Native` bindings, and routes its cryptography through the BoringSSL of +# `package:boring` (`libbssl_dart.so`), another native library. The Dart VM +# resolves `@Native` bindings through a native assets mapping (asset id -> +# library path). `dart run` produces that mapping by executing the build hooks +# of the dependencies and embeds it in the kernel it runs; `dart compile +# jit-snapshot` does not, so a snapshot created by it has no way to find the +# libraries at runtime. # # This script does what `dart run` does, with a mapping that points at the # location of the library in the production image: @@ -23,19 +25,22 @@ # 4. create the app-JIT snapshot from the result (the VM keeps the mapping in # the snapshot, no build hooks or sources are needed at runtime). # -# The library itself is built from the Rust source bundled with the package, -# see `Dockerfile.app`. +# The libraries themselves are built from the sources bundled with the +# packages, see `Dockerfile.app`. # -# Usage: build-server-snapshot.sh +# Usage: build-server-snapshot.sh \ +# \ +# set -euo pipefail -if [ "$#" -ne 2 ]; then - echo "Usage: $0 " >&2 +if [ "$#" -ne 3 ]; then + echo "Usage: $0 " >&2 exit 1 fi OUTPUT="$1" SIGSTORE_LIBRARY="$2" +BORING_LIBRARY="$3" SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &> /dev/null && pwd)" APP_DIR="$SCRIPT_DIR/../app" @@ -65,14 +70,17 @@ cd "$APP_DIR" # 1. The server as kernel. dart compile kernel -o "$WORK_DIR/server.dill" bin/server.dart -# 2. The native assets mapping as kernel component. The asset id is the -# library URI of the `@Native` bindings in `package:sigstore`. +# 2. The native assets mapping as kernel component. The asset ids are the ones +# the build hooks of the packages register: for `package:sigstore` the +# library URI of its `@Native` bindings, for `package:boring` the +# `@DefaultAsset` of its bindings. cat > "$WORK_DIR/native_assets.yaml" <