// 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.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.gerrit.extensions.common.GpgKeyInfo.Status; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.Date; 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; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** Checker for GPG public keys for use in a push certificate. */ public class PublicKeyChecker { private static final Logger log = LoggerFactory.getLogger(PublicKeyChecker.class); // 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 Date effectiveTime = new Date(); /** * 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(Date effectiveTime) { this.effectiveTime = effectiveTime; return this; } protected Date 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<Fingerprint>() : 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, Date now) { List<String> problems = new ArrayList<>(2); gatherRevocationProblems(key, now, problems); long validMs = key.getValidSeconds() * 1000; if (validMs != 0) { long msSinceCreation = now.getTime() - key.getCreationTime().getTime(); if (msSinceCreation > validMs) { problems.add("Key is expired"); } } return CheckResult.create(problems); } private void gatherRevocationProblems(PGPPublicKey key, Date 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, Date 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 || revocation.getCreationTime().before(now); } private PGPSignature scanRevocations( PGPPublicKey key, Date 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. log.info( "Key " + Fingerprint.toString(key.getFingerprint()) + " is revoked by " + Fingerprint.toString(rfp) + ", which is not in the store. Assuming revocation is valid."); problems.add(reasonToString(getRevocationReason(revocation))); continue; } PGPPublicKey rk = revokerKeyRing.getPublicKey(); if (rk.getAlgorithm() != revoker.getAlgorithm()) { continue; } if (!checkBasic(rk, revocation.getCreationTime()).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<>(); @SuppressWarnings("unchecked") 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; } }