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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions doc/repository-spec-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ server, this could work in many different ways.
"latest": {
"version": "<version>",
"retracted": true || false, /* optional field, false if omitted */
"slsa_level": 2, /* optional field, omitted if the version is not attested */
"archive_url": "https://.../archive.tar.gz",
"archive_sha256": "95cbaad58e2cf32d1aa852f20af1fcda1820ead92a4b1447ea7ba1ba18195d27"
"pubspec": {
Expand All @@ -243,6 +244,7 @@ server, this could work in many different ways.
{
"version": "<package>",
"retracted": true || false, /* optional field, false if omitted */
"slsa_level": 2, /* optional field, omitted if the version is not attested */
"archive_url": "https://.../archive.tar.gz",
"archive_sha256": "95cbaad58e2cf32d1aa852f20af1fcda1820ead92a4b1447ea7ba1ba18195d27"
"pubspec": {
Expand Down Expand Up @@ -285,6 +287,81 @@ that the advisories end-point is supported by the server. If present this must
be a timestamp of when the result from the advisories end-point for this package
changed.

The `slsa_level` property is optional. If present, the version has an
attestation, and the client will fetch and verify it, see
[Fetching Attestations](#fetching-attestations).

### Fetching Attestations

**GET** `<hosted-url>/api/packages/<package>/versions/<version>/attestation`

**Response**
* `Content-Type: application/json`

The response body is the Sigstore bundle that was supplied when the version was
published, verbatim.

A repository that reports `slsa_level` for a version **must** serve the
attestation for that version: a client that has been told a version is attested
will fail the download if the attestation cannot be retrieved, rather than
install it unverified. Repositories that do not support attestations should
respond `404` here and omit `slsa_level`.
Comment thread
mosuem marked this conversation as resolved.

Two consequences of this for repository implementers:

* The attestation must be retrievable *before* a version listing that
advertises it (with `slsa_level`) is served. A client that gets a `404` here
does not retry: it fails the download. Repositories that publish the version
listing through a separate system (e.g. an exported bucket) must order the
two accordingly.
* A proxy or mirror that passes the version listing through verbatim, but does
not forward requests to this end-point, breaks every attested package for its
users. Such a proxy must either forward this end-point or strip `slsa_level`
from the listings it serves.

The attestation is not a substitute for the repository being trustworthy about
*which* versions are attested; it is what allows the client to check the archive
without trusting the repository about its *contents*.

#### What the client verifies

For an attested version the client checks that:

* the Sigstore bundle verifies against the Sigstore public-good trusted root,
and was signed by a trusted publishing workflow (currently
`dart-lang/setup-dart/.github/workflows/publish.yml`);
* the SLSA build provenance it carries names the downloaded archive by its
sha256; and
* the repository the provenance says the archive was built from is the
`repository` of the `pubspec.yaml` *inside* the archive.

A passing verification thus proves that the archive was built, unmodified, by
the trusted workflow from the GitHub repository that the archive itself names.
It does **not** prove that this repository is the one authorized to publish the
package: that binding is established by the package repository at publish time,
when it accepts (or rejects) the upload. The client records the verified
repository in `pubspec.lock` (`provenance`) and warns when a later version of
the package was built from another repository; it cannot tell a legitimate move
from a hijack.

#### When the client expects an attestation

The client fetches and verifies an attestation, and fails the download if there
is none, when any of the following holds:

* the version listing reports `slsa_level` for the version;
* `pubspec.lock` records a `provenance` for the version, i.e. it was verified
when the lockfile was last resolved — the only signal that follows the project
to other machines; or
* an attestation has been verified for the same or an earlier version of the
package on this machine. Versions before the first attested one are not
expected to be attested, so they stay installable (`dart pub downgrade`, an
older pin in another project).

A repository that attests a package should therefore keep attesting every later
version of it; clients that have verified one version will reject later
unattested ones.

## Publishing Packages

**GET** `<hosted-url>/api/packages/versions/new`
Expand Down
195 changes: 195 additions & 0 deletions lib/src/sigstore/lockfile_policy.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'package:pub_semver/pub_semver.dart';
import 'package:sigstore/provenance.dart';

import '../exceptions.dart';

/// Exception thrown when package integrity or provenance policies fail.
class PackageProvenanceException extends DataException {
PackageProvenanceException(super.message);
}

/// Compact provenance summary stored in memory or lockfile.
class ProvenanceInfo {
final String repository;
final String? ref;
final String? commitSha;
final String? workflowPath;

ProvenanceInfo({
required this.repository,
this.ref,
this.commitSha,
this.workflowPath,
});

Map<String, dynamic> toJson() => {
'repository': repository,
if (ref != null) 'ref': ref,
if (commitSha != null) 'commitSha': commitSha,
if (workflowPath != null) 'workflowPath': workflowPath,
};

factory ProvenanceInfo.fromJson(Map<String, dynamic> json) {
return ProvenanceInfo(
repository: json['repository'] as String? ?? '',
ref: json['ref'] as String?,
commitSha: json['commitSha'] as String?,
workflowPath: json['workflowPath'] as String?,
);
}

@override
bool operator ==(Object other) =>
other is ProvenanceInfo &&
repository == other.repository &&
ref == other.ref &&
commitSha == other.commitSha &&
workflowPath == other.workflowPath;

@override
int get hashCode => Object.hash(repository, ref, commitSha, workflowPath);
}

/// Why an attestation is expected for a version of a package, and from which
/// repository the package is expected to have been built.
///
/// The package repository says which versions are attested (`slsa_level` in
/// the version listing), but must not be the only one to say so: a compromised
/// repository could simply stop reporting it. These are the two signals the
/// client has of its own.
sealed class AttestationExpectation {
/// The repository the package is expected to have been built from.
String get repository;

/// Where the expectation comes from, as a sentence for a message about
/// [package] [version].
String describe(String package, Version version);

/// What to do if [package] is not attested on purpose, as a sentence.
String howToDrop(String package);
}

/// `pubspec.lock` records that this version was verified as built from
/// [repository] when it was last resolved.
///
/// This is the signal that travels with the project: a machine that has never
/// downloaded the package (say, a fresh CI runner) expects the attestation that
/// the machine that resolved the lockfile verified, and dropping it is a change
/// to the lockfile that shows up in code review.
final class LockedProvenance extends AttestationExpectation {
@override
final String repository;

LockedProvenance(this.repository);

@override
String describe(String package, Version version) =>
'pubspec.lock records that $package $version was verified as built from '
'$repository when it was last resolved.';

@override
String howToDrop(String package) =>
'If $package is no longer attested on purpose, remove the `provenance` '
'entry of $package from pubspec.lock to accept it unverified.';
}

/// An attestation was verified for a version of this package on this machine,
/// which is recorded in the pub cache (`HostedSource.packageProvenancePath`).
///
/// Versions from [since], the lowest attested version seen, on are expected to
/// be attested. Earlier versions are not: they may predate the first attested
/// version of the package, and must stay installable, for `dart pub downgrade`
/// or an older pin in another project on the same machine.
final class PreviouslyAttested extends AttestationExpectation {
@override
final String repository;

/// The lowest version an attestation was verified for.
final Version since;

/// The file in the pub cache recording this, so the message can name it.
final String path;

PreviouslyAttested({
required this.repository,
required this.since,
required this.path,
});

@override
String describe(String package, Version version) =>
'an attestation from $repository was verified for $package $since on '
'this machine, so versions from $since on are expected to be attested.';

@override
String howToDrop(String package) =>
'If $package is no longer attested on purpose, delete `$path` to stop '
'expecting attestations for it on this machine.';
}

/// The client-side policy for the provenance of downloaded packages.
///
/// What a verified attestation proves is that the archive was built by a
/// trusted publishing workflow from the GitHub repository the archive's own
/// pubspec names. Whether that repository is the one authorized to publish the
/// package is established by the package repository at publish time, not here:
/// the client can only check that an attestation is there when one is expected,
/// and notice when the repository changes.
class ProvenancePolicy {
/// Checks [verified], the provenance established by verifying the attestation
/// of [package] [version] (`null` if it was not attested), against
/// [expectation] (`null` if no attestation was expected).
///
/// Throws a [PackageProvenanceException] if an attestation was expected and
/// there is none, naming [repositoryUrl], the package repository, and what to
/// do about it. Reports a package built from another repository than
/// expected to [onRepositoryChange], or throws if [fatalOnRepositoryChange].
static void check({
required String package,
required Version version,
required ProvenanceInfo? verified,
required AttestationExpectation? expectation,
required String repositoryUrl,
bool fatalOnRepositoryChange = false,
void Function(String message)? onRepositoryChange,
}) {
if (expectation == null) return;

if (verified == null) {
throw PackageProvenanceException('''
Package $package $version is not attested, but an attestation is expected:
${expectation.describe(package, version)}

The package repository `$repositoryUrl` served no attestation for it.
${expectation.howToDrop(package)}
''');
}

if (!repositoriesMatch(expectation.repository, verified.repository)) {
final message =
'Package $package $version is attested as built from '
'${verified.repository}, but '
'${expectation.describe(package, version)}';
if (fatalOnRepositoryChange) {
throw PackageProvenanceException(message);
}
onRepositoryChange?.call(message);
}
}

/// Whether [a] and [b] name the same repository.
///
/// GitHub repositories compare by owner and name, case-insensitively and
/// regardless of the URL form (see [GitHubRepository.tryParse]); anything
/// else compares as a string.
static bool repositoriesMatch(String a, String b) {
final repoA = GitHubRepository.tryParse(a);
final repoB = GitHubRepository.tryParse(b);
if (repoA != null && repoB != null) return repoA == repoB;
return a.trim().toLowerCase() == b.trim().toLowerCase();
}
}
5 changes: 5 additions & 0 deletions lib/src/sigstore/sigstore.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

export 'sigstore_stub.dart' if (dart.library.ffi) 'sigstore_real.dart';
5 changes: 5 additions & 0 deletions lib/src/sigstore/sigstore_real.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

export 'package:sigstore/sigstore.dart';
55 changes: 55 additions & 0 deletions lib/src/sigstore/sigstore_stub.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

/// Stub classes for `package:sigstore` when compiling for platforms without
/// `dart:ffi`.
library;

/// Mirrors the error thrown by `package:sigstore`.
///
/// Note that this is a plain enum: it implements neither `Exception` nor
/// `Error`, so it is only caught by `on SigstoreError` or by a catch-all.
enum SigstoreError { invalidBundle, verificationFailed, internalError }

class SigstoreBundle {
static SigstoreBundle fromJson(String json) =>
throw UnsupportedError('Sigstore is not supported on this platform.');

String toJson() =>
throw UnsupportedError('Sigstore is not supported on this platform.');
}

class SigstoreClient {
static SigstoreClient create() =>
throw UnsupportedError('Sigstore is not supported on this platform.');

SigstoreVerificationResult verify(
List<int> artifactBytes,
bool isDigest,
SigstoreBundle bundle,
SigstoreVerificationPolicy policy,
) => throw UnsupportedError('Sigstore is not supported on this platform.');
}

class SigstoreVerificationPolicy {
static SigstoreVerificationPolicy create(
String expectedIdentity,
String expectedIssuer,
bool offline,
bool isStaging,
String trustedRootJson,
String publicKeyPem,
) => throw UnsupportedError('Sigstore is not supported on this platform.');
}

class SigstoreVerificationResult {
bool isValid() =>
throw UnsupportedError('Sigstore is not supported on this platform.');

String verifiedIdentity() =>
throw UnsupportedError('Sigstore is not supported on this platform.');

String verifiedIssuer() =>
throw UnsupportedError('Sigstore is not supported on this platform.');
}
Loading
Loading