blob: 0a962120ad1a707b035dd9ce9527bb25a015d0d8 [file] [log] [blame]
// Copyright (C) 2015 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.gerrit.gpg;
import static com.google.common.flogger.LazyArgs.lazy;
import static com.google.gerrit.extensions.common.GpgKeyInfo.Status.BAD;
import static com.google.gerrit.extensions.common.GpgKeyInfo.Status.OK;
import static com.google.gerrit.extensions.common.GpgKeyInfo.Status.TRUSTED;
import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
import static org.bouncycastle.bcpg.SignatureSubpacketTags.REVOCATION_KEY;
import static org.bouncycastle.bcpg.SignatureSubpacketTags.REVOCATION_REASON;
import static org.bouncycastle.bcpg.sig.RevocationReasonTags.KEY_COMPROMISED;
import static org.bouncycastle.bcpg.sig.RevocationReasonTags.KEY_RETIRED;
import static org.bouncycastle.bcpg.sig.RevocationReasonTags.KEY_SUPERSEDED;
import static org.bouncycastle.bcpg.sig.RevocationReasonTags.NO_REASON;
import static org.bouncycastle.openpgp.PGPSignature.DIRECT_KEY;
import static org.bouncycastle.openpgp.PGPSignature.KEY_REVOCATION;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.extensions.common.GpgKeyInfo.Status;
import java.io.IOException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.bouncycastle.bcpg.SignatureSubpacket;
import org.bouncycastle.bcpg.SignatureSubpacketTags;
import org.bouncycastle.bcpg.sig.RevocationKey;
import org.bouncycastle.bcpg.sig.RevocationReason;
import org.bouncycastle.openpgp.PGPException;
import org.bouncycastle.openpgp.PGPPublicKey;
import org.bouncycastle.openpgp.PGPPublicKeyRing;
import org.bouncycastle.openpgp.PGPPublicKeyRingCollection;
import org.bouncycastle.openpgp.PGPSignature;
import org.bouncycastle.openpgp.operator.bc.BcPGPContentVerifierBuilderProvider;
/** Checker for GPG public keys for use in a push certificate. */
public class PublicKeyChecker {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
// https://tools.ietf.org/html/rfc4880#section-5.2.3.13
private static final int COMPLETE_TRUST = 120;
private PublicKeyStore store;
private Map<Long, Fingerprint> trusted;
private int maxTrustDepth;
private Instant effectiveTime = Instant.now();
/**
* Enable web-of-trust checks.
*
* <p>If enabled, a store must be set with {@link #setStore(PublicKeyStore)}. (These methods are
* separate since the store is a closeable resource that may not be available when reading trusted
* keys from a config.)
*
* @param maxTrustDepth maximum depth to search while looking for a trusted key.
* @param trusted ultimately trusted key fingerprints, keyed by fingerprint; may not be empty. To
* construct a map, see {@link Fingerprint#byId(Iterable)}.
* @return a reference to this object.
*/
public PublicKeyChecker enableTrust(int maxTrustDepth, Map<Long, Fingerprint> trusted) {
if (maxTrustDepth <= 0) {
throw new IllegalArgumentException("maxTrustDepth must be positive, got: " + maxTrustDepth);
}
if (trusted == null || trusted.isEmpty()) {
throw new IllegalArgumentException("at least one trusted key is required");
}
this.maxTrustDepth = maxTrustDepth;
this.trusted = trusted;
return this;
}
/** Disable web-of-trust checks. */
public PublicKeyChecker disableTrust() {
trusted = null;
return this;
}
/** Set the public key store for reading keys referenced in signatures. */
public PublicKeyChecker setStore(PublicKeyStore store) {
if (store == null) {
throw new IllegalArgumentException("PublicKeyStore is required");
}
this.store = store;
return this;
}
/**
* Set the effective time for checking the key.
*
* <p>If set, check whether the key should be considered valid (e.g. unexpired) as of this time.
*
* @param effectiveTime effective time.
* @return a reference to this object.
*/
public PublicKeyChecker setEffectiveTime(Instant effectiveTime) {
this.effectiveTime = effectiveTime;
return this;
}
protected Instant getEffectiveTime() {
return effectiveTime;
}
/**
* Check a public key.
*
* @param key the public key.
* @return the result of the check.
*/
public final CheckResult check(PGPPublicKey key) {
if (store == null) {
throw new IllegalStateException("PublicKeyStore is required");
}
return check(key, 0, true, trusted != null ? new HashSet<>() : null);
}
/**
* Perform custom checks.
*
* <p>Default implementation reports no problems, but may be overridden by subclasses.
*
* @param key the public key.
* @param depth the depth from the initial key passed to {@link #check( PGPPublicKey)}: 0 if this
* was the initial key, up to a maximum of {@code maxTrustDepth}.
* @return the result of the custom check.
*/
public CheckResult checkCustom(PGPPublicKey key, int depth) {
return CheckResult.ok();
}
private CheckResult check(PGPPublicKey key, int depth, boolean expand, Set<Fingerprint> seen) {
CheckResult basicResult = checkBasic(key, effectiveTime);
CheckResult customResult = checkCustom(key, depth);
CheckResult trustResult = checkWebOfTrust(key, store, depth, seen);
if (!expand && !trustResult.isTrusted()) {
trustResult = CheckResult.create(trustResult.getStatus(), "Key is not trusted");
}
List<String> problems =
new ArrayList<>(
basicResult.getProblems().size()
+ customResult.getProblems().size()
+ trustResult.getProblems().size());
problems.addAll(basicResult.getProblems());
problems.addAll(customResult.getProblems());
problems.addAll(trustResult.getProblems());
Status status;
if (basicResult.getStatus() == BAD
|| customResult.getStatus() == BAD
|| trustResult.getStatus() == BAD) {
// Any BAD result and the final result is BAD.
status = BAD;
} else if (trustResult.getStatus() == TRUSTED) {
// basicResult is BAD or OK, whereas trustResult is BAD or TRUSTED. If
// TRUSTED, we trust the final result.
status = TRUSTED;
} else {
// All results were OK or better, but trustResult was not TRUSTED. Don't
// let subclasses bypass checkWebOfTrust by returning TRUSTED; just return
// OK here.
status = OK;
}
return CheckResult.create(status, problems);
}
private CheckResult checkBasic(PGPPublicKey key, Instant now) {
List<String> problems = new ArrayList<>(2);
gatherRevocationProblems(key, now, problems);
long validMs = key.getValidSeconds() * 1000;
if (validMs != 0) {
long msSinceCreation = now.toEpochMilli() - getCreationTime(key).toEpochMilli();
if (msSinceCreation > validMs) {
problems.add("Key is expired");
}
}
return CheckResult.create(problems);
}
private void gatherRevocationProblems(PGPPublicKey key, Instant now, List<String> problems) {
try {
List<PGPSignature> revocations = new ArrayList<>();
Map<Long, RevocationKey> revokers = new HashMap<>();
PGPSignature selfRevocation = scanRevocations(key, now, revocations, revokers);
if (selfRevocation != null) {
RevocationReason reason = getRevocationReason(selfRevocation);
if (isRevocationValid(selfRevocation, reason, now)) {
problems.add(reasonToString(reason));
}
} else {
checkRevocations(key, revocations, revokers, problems);
}
} catch (PGPException | IOException e) {
problems.add("Error checking key revocation");
}
}
private static boolean isRevocationValid(
PGPSignature revocation, RevocationReason reason, Instant now) {
// RFC4880 states:
// "If a key has been revoked because of a compromise, all signatures
// created by that key are suspect. However, if it was merely superseded or
// retired, old signatures are still valid."
//
// Note that GnuPG does not implement this correctly, as it does not
// consider the revocation reason and timestamp when checking whether a
// signature (data or certification) is valid.
return reason.getRevocationReason() == KEY_COMPROMISED
|| PushCertificateChecker.getCreationTime(revocation).isBefore(now);
}
private PGPSignature scanRevocations(
PGPPublicKey key,
Instant now,
List<PGPSignature> revocations,
Map<Long, RevocationKey> revokers)
throws PGPException {
@SuppressWarnings("unchecked")
Iterator<PGPSignature> allSigs = key.getSignatures();
while (allSigs.hasNext()) {
PGPSignature sig = allSigs.next();
switch (sig.getSignatureType()) {
case KEY_REVOCATION:
if (sig.getKeyID() == key.getKeyID()) {
sig.init(new BcPGPContentVerifierBuilderProvider(), key);
if (sig.verifyCertification(key)) {
return sig;
}
} else {
RevocationReason reason = getRevocationReason(sig);
if (reason != null && isRevocationValid(sig, reason, now)) {
revocations.add(sig);
}
}
break;
case DIRECT_KEY:
RevocationKey r = getRevocationKey(key, sig);
if (r != null) {
revokers.put(Fingerprint.getId(r.getFingerprint()), r);
}
break;
}
}
return null;
}
private RevocationKey getRevocationKey(PGPPublicKey key, PGPSignature sig) throws PGPException {
if (sig.getKeyID() != key.getKeyID()) {
return null;
}
SignatureSubpacket sub = sig.getHashedSubPackets().getSubpacket(REVOCATION_KEY);
if (sub == null) {
return null;
}
sig.init(new BcPGPContentVerifierBuilderProvider(), key);
if (!sig.verifyCertification(key)) {
return null;
}
return new RevocationKey(sub.isCritical(), sub.isLongLength(), sub.getData());
}
private void checkRevocations(
PGPPublicKey key,
List<PGPSignature> revocations,
Map<Long, RevocationKey> revokers,
List<String> problems)
throws PGPException, IOException {
for (PGPSignature revocation : revocations) {
RevocationKey revoker = revokers.get(revocation.getKeyID());
if (revoker == null) {
continue; // Not a designated revoker.
}
byte[] rfp = revoker.getFingerprint();
PGPPublicKeyRing revokerKeyRing = store.get(rfp);
if (revokerKeyRing == null) {
// Revoker is authorized and there is a revocation signature by this
// revoker, but the key is not in the store so we can't verify the
// signature.
logger.atInfo().log(
"Key %s is revoked by %s, which is not in the store. Assuming revocation is valid.",
lazy(() -> Fingerprint.toString(key.getFingerprint())),
lazy(() -> Fingerprint.toString(rfp)));
problems.add(reasonToString(getRevocationReason(revocation)));
continue;
}
PGPPublicKey rk = revokerKeyRing.getPublicKey();
if (rk.getAlgorithm() != revoker.getAlgorithm()) {
continue;
}
if (!checkBasic(rk, PushCertificateChecker.getCreationTime(revocation)).isOk()) {
// Revoker's key was expired or revoked at time of revocation, so the
// revocation is invalid.
continue;
}
revocation.init(new BcPGPContentVerifierBuilderProvider(), rk);
if (revocation.verifyCertification(key)) {
problems.add(reasonToString(getRevocationReason(revocation)));
}
}
}
private static RevocationReason getRevocationReason(PGPSignature sig) {
if (sig.getSignatureType() != KEY_REVOCATION) {
throw new IllegalArgumentException(
"Expected KEY_REVOCATION signature, got " + sig.getSignatureType());
}
SignatureSubpacket sub = sig.getHashedSubPackets().getSubpacket(REVOCATION_REASON);
if (sub == null) {
return null;
}
return new RevocationReason(sub.isCritical(), sub.isLongLength(), sub.getData());
}
private static String reasonToString(RevocationReason reason) {
StringBuilder r = new StringBuilder("Key is revoked (");
if (reason == null) {
return r.append("no reason provided)").toString();
}
switch (reason.getRevocationReason()) {
case NO_REASON:
r.append("no reason code specified");
break;
case KEY_SUPERSEDED:
r.append("superseded");
break;
case KEY_COMPROMISED:
r.append("key material has been compromised");
break;
case KEY_RETIRED:
r.append("retired and no longer valid");
break;
default:
r.append("reason code ").append(Integer.toString(reason.getRevocationReason())).append(')');
break;
}
r.append(')');
String desc = reason.getRevocationDescription();
if (!desc.isEmpty()) {
r.append(": ").append(desc);
}
return r.toString();
}
private CheckResult checkWebOfTrust(
PGPPublicKey key, PublicKeyStore store, int depth, Set<Fingerprint> seen) {
if (trusted == null) {
// Trust checking not configured, server trusts all OK keys.
return CheckResult.trusted();
}
Fingerprint fp = new Fingerprint(key.getFingerprint());
if (seen.contains(fp)) {
return CheckResult.ok("Key is trusted in a cycle");
}
seen.add(fp);
Fingerprint trustedFp = trusted.get(key.getKeyID());
if (trustedFp != null && trustedFp.equals(fp)) {
return CheckResult.trusted(); // Directly trusted.
} else if (depth >= maxTrustDepth) {
return CheckResult.ok("No path of depth <= " + maxTrustDepth + " to a trusted key");
}
List<CheckResult> signerResults = new ArrayList<>();
Iterator<String> userIds = key.getUserIDs();
while (userIds.hasNext()) {
String userId = userIds.next();
// Don't check the timestamp of these certifications. This allows admins
// to correct untrusted keys by signing them with a trusted key, such that
// older signatures created by those keys retroactively appear valid.
Iterator<PGPSignature> sigs = key.getSignaturesForID(userId);
while (sigs.hasNext()) {
PGPSignature sig = sigs.next();
// TODO(dborowitz): Handle CERTIFICATION_REVOCATION.
if (sig.getSignatureType() != PGPSignature.DEFAULT_CERTIFICATION
&& sig.getSignatureType() != PGPSignature.POSITIVE_CERTIFICATION) {
continue; // Not a certification.
}
PGPPublicKey signer = getSigner(store, sig, userId, key, signerResults);
// TODO(dborowitz): Require self certification.
if (signer == null || Arrays.equals(signer.getFingerprint(), key.getFingerprint())) {
continue;
}
String subpacketProblem = checkTrustSubpacket(sig, depth);
if (subpacketProblem == null) {
CheckResult signerResult = check(signer, depth + 1, false, seen);
if (signerResult.isTrusted()) {
return CheckResult.trusted();
}
}
signerResults.add(
CheckResult.ok(
"Certification by " + keyToString(signer) + " is valid, but key is not trusted"));
}
}
List<String> problems = new ArrayList<>();
problems.add("No path to a trusted key");
for (CheckResult signerResult : signerResults) {
problems.addAll(signerResult.getProblems());
}
return CheckResult.create(OK, problems);
}
private static PGPPublicKey getSigner(
PublicKeyStore store,
PGPSignature sig,
String userId,
PGPPublicKey key,
List<CheckResult> results) {
try {
PGPPublicKeyRingCollection signers = store.get(sig.getKeyID());
if (!signers.getKeyRings().hasNext()) {
results.add(
CheckResult.ok(
"Key "
+ keyIdToString(sig.getKeyID())
+ " used for certification is not in store"));
return null;
}
PGPPublicKey signer = PublicKeyStore.getSigner(signers, sig, userId, key);
if (signer == null) {
results.add(
CheckResult.ok("Certification by " + keyIdToString(sig.getKeyID()) + " is not valid"));
return null;
}
return signer;
} catch (PGPException | IOException e) {
results.add(
CheckResult.ok("Error checking certification by " + keyIdToString(sig.getKeyID())));
return null;
}
}
private String checkTrustSubpacket(PGPSignature sig, int depth) {
SignatureSubpacket trustSub =
sig.getHashedSubPackets().getSubpacket(SignatureSubpacketTags.TRUST_SIG);
if (trustSub == null || trustSub.getData().length != 2) {
return "Certification is missing trust information";
}
byte amount = trustSub.getData()[1];
if (amount < COMPLETE_TRUST) {
return "Certification does not fully trust key";
}
byte level = trustSub.getData()[0];
int required = depth + 1;
if (level < required) {
return "Certification trusts to depth " + level + ", but depth " + required + " is required";
}
return null;
}
@SuppressWarnings("JdkObsolete")
private static Instant getCreationTime(PGPPublicKey key) {
return key.getCreationTime().toInstant();
}
}