Skip to content

Commit 4450784

Browse files
Close #1251: Allow no transparency-log entries
* Add support for verifying bundles without transparency-log entries, relying on signed RFC 3161 timestamps.
1 parent 5e523f3 commit 4450784

5 files changed

Lines changed: 112 additions & 13 deletions

File tree

sigstore-cli/src/main/java/dev/sigstore/cli/Verify.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,16 @@ static class Policy {
113113
required = false)
114114
Path workingDirectory;
115115

116+
@Option(
117+
names = {"--allow-non-transparency-log"},
118+
description =
119+
"verify a bundle that has no transparency-log entry, relying on a signed RFC 3161 "
120+
+ "timestamp instead (reduced assurance; for instances that do not publish to a "
121+
+ "transparency log, e.g. GitHub's Sigstore instance for private repositories)",
122+
required = false,
123+
defaultValue = "false")
124+
Boolean allowNonTransparencyLog;
125+
116126
@Override
117127
public Integer call() throws Exception {
118128
byte[] digest;
@@ -142,6 +152,9 @@ public Integer call() throws Exception {
142152
.subjectAlternativeName(StringMatcher.string(policy.certificateSan))
143153
.build());
144154
}
155+
if (allowNonTransparencyLog) {
156+
verificationOptionsBuilder.allowNonTransparencyLogVerification(true);
157+
}
145158
var verificationOptions = verificationOptionsBuilder.build();
146159

147160
KeylessVerifier verifier;

sigstore-java/src/main/java/dev/sigstore/KeylessVerifier.java

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,18 @@ public Builder trustedRootProvider(TrustedRootProvider trustedRootProvider) {
132132
private AlgorithmRegistry.HashAlgorithm findHashAlgorithm(Bundle bundle)
133133
throws KeylessVerificationException {
134134
try {
135+
// When the bundle has no transparency-log entry (e.g. a signed-timestamp-only bundle),
136+
// derive the hash algorithm without one: DSSE bundles use SHA-256 (as in the dsse:0.0.1
137+
// case below), otherwise fall back to the signing certificate's algorithm. Whether such a
138+
// bundle is permitted at all is enforced in verify(...) via
139+
// VerificationOptions#allowNonTransparencyLogVerification.
140+
if (bundle.getEntries().isEmpty()) {
141+
if (bundle.getDsseEnvelope().isPresent()) {
142+
return AlgorithmRegistry.HashAlgorithm.SHA2_256;
143+
}
144+
var publicKey = Certificates.getLeaf(bundle.getCertPath()).getPublicKey();
145+
return AlgorithmRegistry.getSigningAlgorithm(publicKey).getHashAlgorithm();
146+
}
135147
var rekorEntry = bundle.getEntries().get(0);
136148
var body = rekorEntry.getBodyDecoded();
137149
if ("0.0.1".equals(body.getApiVersion())) {
@@ -199,7 +211,8 @@ public void verify(byte[] artifactDigest, Bundle bundle, VerificationOptions opt
199211

200212
// a trusted CT log
201213
try {
202-
fulcioVerifier.verifySigningCertificate(signingCert);
214+
fulcioVerifier.verifySigningCertificate(
215+
signingCert, options.allowNonTransparencyLogVerification());
203216
} catch (FulcioVerificationException | IOException ex) {
204217
throw new KeylessVerificationException(
205218
"Fulcio certificate was not valid: " + ex.getMessage(), ex);
@@ -209,7 +222,20 @@ public void verify(byte[] artifactDigest, Bundle bundle, VerificationOptions opt
209222
checkCertificateMatchers(leafCert, options.getCertificateMatchers());
210223

211224
var hashAlgorithm = findHashAlgorithm(bundle);
212-
var rekorEntry = bundle.getEntries().get(0);
225+
226+
// A transparency-log entry is required by default. Bundles from instances that do not publish
227+
// to a log (e.g. GitHub's Sigstore instance for private repositories) carry only a signed
228+
// timestamp; verifying those requires opting in and relies on the RFC 3161 timestamp (verified
229+
// below) for trusted time.
230+
var entries = bundle.getEntries();
231+
if (entries.isEmpty() && !options.allowNonTransparencyLogVerification()) {
232+
throw new KeylessVerificationException(
233+
"Bundle does not contain a transparency log entry. If it is from an instance that does "
234+
+ "not publish to a transparency log, set "
235+
+ "VerificationOptions.allowNonTransparencyLogVerification(true) to verify using its "
236+
+ "signed timestamp instead.");
237+
}
238+
var rekorEntry = entries.isEmpty() ? null : entries.get(0);
213239

214240
byte[] signature;
215241
if (bundle.getMessageSignature().isPresent()) { // hashedrekord
@@ -222,16 +248,20 @@ public void verify(byte[] artifactDigest, Bundle bundle, VerificationOptions opt
222248
signature = dsseEnvelope.getSignature();
223249
}
224250

225-
try {
226-
rekorVerifier.verifyEntry(rekorEntry);
227-
} catch (RekorVerificationException ex) {
228-
throw new KeylessVerificationException("Transparency log entry could not be verified", ex);
251+
// A transparency-log entry that is present is always verified; opting in only relaxes the
252+
// requirement that one exists, never the verification of one that is provided.
253+
Instant entryTime = null;
254+
if (rekorEntry != null) {
255+
try {
256+
rekorVerifier.verifyEntry(rekorEntry);
257+
} catch (RekorVerificationException ex) {
258+
throw new KeylessVerificationException("Transparency log entry could not be verified", ex);
259+
}
260+
// if entry was verified and has a SET, get time from it
261+
var set = rekorEntry.getVerification().getSignedEntryTimestamp();
262+
entryTime = set != null ? rekorEntry.getIntegratedTimeInstant() : null;
229263
}
230264

231-
// if entry was verified and has a SET, get time from it
232-
var set = rekorEntry.getVerification().getSignedEntryTimestamp();
233-
var entryTime = set != null ? rekorEntry.getIntegratedTimeInstant() : null;
234-
235265
verifyTimestamps(leafCert, bundle.getTimestamps(), entryTime, signature);
236266
}
237267

@@ -325,6 +355,12 @@ private void checkMessageSignature(
325355
"Signature could not be processed: " + ex.getMessage(), ex);
326356
}
327357

358+
// No transparency-log entry (allowed via VerificationOptions): the artifact digest and
359+
// signature have been verified above, and there is nothing to cross-check against.
360+
if (rekorEntry == null) {
361+
return;
362+
}
363+
328364
// recreate the log entry and check if it matches what was provided in the entry
329365
String version;
330366
try {
@@ -469,6 +505,12 @@ private void checkDsseEnvelope(
469505
throw new KeylessVerificationException("Signature could not be processed", se);
470506
}
471507

508+
// No transparency-log entry (allowed via VerificationOptions): the payload subject digest and
509+
// DSSE signature have been verified above, and there is nothing to cross-check against.
510+
if (rekorEntry == null) {
511+
return;
512+
}
513+
472514
RekorEntryBody entryBody;
473515
try {
474516
entryBody = rekorEntry.getBodyDecoded();

sigstore-java/src/main/java/dev/sigstore/VerificationOptions.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import java.security.cert.X509Certificate;
2121
import java.util.List;
2222
import java.util.function.Predicate;
23+
import org.immutables.value.Value.Default;
2324
import org.immutables.value.Value.Immutable;
2425

2526
@Immutable(singleton = true)
@@ -28,6 +29,24 @@ public interface VerificationOptions {
2829
/** An allow list of certificate identities to match with. */
2930
List<CertificateMatcher> getCertificateMatchers();
3031

32+
/**
33+
* Whether to allow verifying a bundle that contains no transparency-log entry, relying solely on
34+
* a signed RFC 3161 timestamp for trusted time. Defaults to {@code false}, matching the Sigstore
35+
* public-good instance where a transparency-log entry is always present and required.
36+
*
37+
* <p>Enabling this weakens the guarantees: without a transparency-log entry the signature is not
38+
* publicly monitorable/auditable. Only enable it for bundles from instances that intentionally do
39+
* not publish to a transparency log (for example, GitHub's Sigstore instance for private
40+
* repositories), and pair it with a pinned trusted root. A signed timestamp is still required.
41+
*
42+
* <p>This only relaxes the <em>requirement</em> that a transparency-log entry exists; a
43+
* transparency-log entry that <em>is</em> present in the bundle is still fully verified.
44+
*/
45+
@Default
46+
default boolean allowNonTransparencyLogVerification() {
47+
return false;
48+
}
49+
3150
/**
3251
* An interface for allowing matching of certificates. Use {@link #fulcio()} to instantiate the
3352
* default {@link FulcioCertificateMatcher} implementation. Custom implementations may throw

sigstore-java/src/main/java/dev/sigstore/bundle/BundleReader.java

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,11 @@ static Bundle readBundle(Reader jsonReader) throws BundleParseException {
5050

5151
bundleBuilder.mediaType(protoBundle.getMediaType());
5252

53-
if (protoBundle.getVerificationMaterial().getTlogEntriesCount() == 0) {
54-
throw new BundleParseException("Could not find any tlog entries in bundle json");
55-
}
53+
// A bundle may legitimately carry no transparency-log entry (for example, GitHub's Sigstore
54+
// instance for private repositories, which relies on a signed RFC 3161 timestamp instead).
55+
// Such bundles are parsed here; whether their absence is acceptable is a verification-time
56+
// policy decision (see VerificationOptions#allowNonTransparencyLogVerification), not a parse
57+
// error. Any tlog entries that ARE present must still be well-formed (inclusion proof required).
5658
for (var bundleEntry : protoBundle.getVerificationMaterial().getTlogEntriesList()) {
5759
if (!bundleEntry.hasInclusionProof()) {
5860
// all consumed bundles must have an inclusion proof

sigstore-java/src/main/java/dev/sigstore/fulcio/client/FulcioVerifier.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,30 @@ private void verifyEmbeddedScts(CertPath certPath) throws FulcioVerificationExce
150150
*/
151151
public void verifySigningCertificate(CertPath signingCertificate)
152152
throws FulcioVerificationException, IOException {
153+
verifySigningCertificate(signingCertificate, false);
154+
}
155+
156+
/**
157+
* Verify a signing certificate, optionally tolerating a trust root that provides no certificate
158+
* transparency logs.
159+
*
160+
* @param signingCertificate the chain to verify
161+
* @param allowEmptyCtLogs when {@code true} and the configured trust root declares <em>no</em> CT
162+
* logs, SCT verification is skipped. This is required for Sigstore instances that do not use
163+
* certificate transparency (for example, GitHub's Sigstore instance for private repositories,
164+
* whose trust root carries zero CT logs and whose certificates embed no SCT). When {@code
165+
* false} (the default) a missing CT log is an error, preserving the strict behavior for the
166+
* public-good instance. If the trust root <em>does</em> provide CT logs, SCTs are always
167+
* verified regardless of this flag.
168+
*/
169+
public void verifySigningCertificate(CertPath signingCertificate, boolean allowEmptyCtLogs)
170+
throws FulcioVerificationException, IOException {
153171
CertPath fullCertPath = validateCertPath(signingCertificate);
172+
if (ctLogs.isEmpty() && allowEmptyCtLogs) {
173+
// The trust root declares no certificate-transparency logs, so there is nothing to verify an
174+
// SCT against. Trusted time is established elsewhere (a signed RFC 3161 timestamp).
175+
return;
176+
}
154177
verifySct(fullCertPath);
155178
}
156179

0 commit comments

Comments
 (0)