LCOV - code coverage report
Current view: top level - gpg - PublicKeyChecker.java (source / functions) Hit Total Coverage
Test: _coverage_report.dat Lines: 178 208 85.6 %
Date: 2022-11-19 15:00:39 Functions: 23 24 95.8 %

          Line data    Source code
       1             : // Copyright (C) 2015 The Android Open Source Project
       2             : //
       3             : // Licensed under the Apache License, Version 2.0 (the "License");
       4             : // you may not use this file except in compliance with the License.
       5             : // You may obtain a copy of the License at
       6             : //
       7             : // http://www.apache.org/licenses/LICENSE-2.0
       8             : //
       9             : // Unless required by applicable law or agreed to in writing, software
      10             : // distributed under the License is distributed on an "AS IS" BASIS,
      11             : // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      12             : // See the License for the specific language governing permissions and
      13             : // limitations under the License.
      14             : 
      15             : package com.google.gerrit.gpg;
      16             : 
      17             : import static com.google.common.flogger.LazyArgs.lazy;
      18             : import static com.google.gerrit.extensions.common.GpgKeyInfo.Status.BAD;
      19             : import static com.google.gerrit.extensions.common.GpgKeyInfo.Status.OK;
      20             : import static com.google.gerrit.extensions.common.GpgKeyInfo.Status.TRUSTED;
      21             : import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
      22             : import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
      23             : import static org.bouncycastle.bcpg.SignatureSubpacketTags.REVOCATION_KEY;
      24             : import static org.bouncycastle.bcpg.SignatureSubpacketTags.REVOCATION_REASON;
      25             : import static org.bouncycastle.bcpg.sig.RevocationReasonTags.KEY_COMPROMISED;
      26             : import static org.bouncycastle.bcpg.sig.RevocationReasonTags.KEY_RETIRED;
      27             : import static org.bouncycastle.bcpg.sig.RevocationReasonTags.KEY_SUPERSEDED;
      28             : import static org.bouncycastle.bcpg.sig.RevocationReasonTags.NO_REASON;
      29             : import static org.bouncycastle.openpgp.PGPSignature.DIRECT_KEY;
      30             : import static org.bouncycastle.openpgp.PGPSignature.KEY_REVOCATION;
      31             : 
      32             : import com.google.common.flogger.FluentLogger;
      33             : import com.google.gerrit.common.Nullable;
      34             : import com.google.gerrit.extensions.common.GpgKeyInfo.Status;
      35             : import java.io.IOException;
      36             : import java.time.Instant;
      37             : import java.util.ArrayList;
      38             : import java.util.Arrays;
      39             : import java.util.HashMap;
      40             : import java.util.HashSet;
      41             : import java.util.Iterator;
      42             : import java.util.List;
      43             : import java.util.Map;
      44             : import java.util.Set;
      45             : import org.bouncycastle.bcpg.SignatureSubpacket;
      46             : import org.bouncycastle.bcpg.SignatureSubpacketTags;
      47             : import org.bouncycastle.bcpg.sig.RevocationKey;
      48             : import org.bouncycastle.bcpg.sig.RevocationReason;
      49             : import org.bouncycastle.openpgp.PGPException;
      50             : import org.bouncycastle.openpgp.PGPPublicKey;
      51             : import org.bouncycastle.openpgp.PGPPublicKeyRing;
      52             : import org.bouncycastle.openpgp.PGPPublicKeyRingCollection;
      53             : import org.bouncycastle.openpgp.PGPSignature;
      54             : import org.bouncycastle.openpgp.operator.bc.BcPGPContentVerifierBuilderProvider;
      55             : 
      56             : /** Checker for GPG public keys for use in a push certificate. */
      57           3 : public class PublicKeyChecker {
      58           3 :   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
      59             : 
      60             :   // https://tools.ietf.org/html/rfc4880#section-5.2.3.13
      61             :   private static final int COMPLETE_TRUST = 120;
      62             : 
      63             :   private PublicKeyStore store;
      64             :   private Map<Long, Fingerprint> trusted;
      65             :   private int maxTrustDepth;
      66           3 :   private Instant effectiveTime = Instant.now();
      67             : 
      68             :   /**
      69             :    * Enable web-of-trust checks.
      70             :    *
      71             :    * <p>If enabled, a store must be set with {@link #setStore(PublicKeyStore)}. (These methods are
      72             :    * separate since the store is a closeable resource that may not be available when reading trusted
      73             :    * keys from a config.)
      74             :    *
      75             :    * @param maxTrustDepth maximum depth to search while looking for a trusted key.
      76             :    * @param trusted ultimately trusted key fingerprints, keyed by fingerprint; may not be empty. To
      77             :    *     construct a map, see {@link Fingerprint#byId(Iterable)}.
      78             :    * @return a reference to this object.
      79             :    */
      80             :   public PublicKeyChecker enableTrust(int maxTrustDepth, Map<Long, Fingerprint> trusted) {
      81           1 :     if (maxTrustDepth <= 0) {
      82           0 :       throw new IllegalArgumentException("maxTrustDepth must be positive, got: " + maxTrustDepth);
      83             :     }
      84           1 :     if (trusted == null || trusted.isEmpty()) {
      85           0 :       throw new IllegalArgumentException("at least one trusted key is required");
      86             :     }
      87           1 :     this.maxTrustDepth = maxTrustDepth;
      88           1 :     this.trusted = trusted;
      89           1 :     return this;
      90             :   }
      91             : 
      92             :   /** Disable web-of-trust checks. */
      93             :   public PublicKeyChecker disableTrust() {
      94           3 :     trusted = null;
      95           3 :     return this;
      96             :   }
      97             : 
      98             :   /** Set the public key store for reading keys referenced in signatures. */
      99             :   public PublicKeyChecker setStore(PublicKeyStore store) {
     100           3 :     if (store == null) {
     101           0 :       throw new IllegalArgumentException("PublicKeyStore is required");
     102             :     }
     103           3 :     this.store = store;
     104           3 :     return this;
     105             :   }
     106             : 
     107             :   /**
     108             :    * Set the effective time for checking the key.
     109             :    *
     110             :    * <p>If set, check whether the key should be considered valid (e.g. unexpired) as of this time.
     111             :    *
     112             :    * @param effectiveTime effective time.
     113             :    * @return a reference to this object.
     114             :    */
     115             :   public PublicKeyChecker setEffectiveTime(Instant effectiveTime) {
     116           1 :     this.effectiveTime = effectiveTime;
     117           1 :     return this;
     118             :   }
     119             : 
     120             :   protected Instant getEffectiveTime() {
     121           0 :     return effectiveTime;
     122             :   }
     123             : 
     124             :   /**
     125             :    * Check a public key.
     126             :    *
     127             :    * @param key the public key.
     128             :    * @return the result of the check.
     129             :    */
     130             :   public final CheckResult check(PGPPublicKey key) {
     131           3 :     if (store == null) {
     132           0 :       throw new IllegalStateException("PublicKeyStore is required");
     133             :     }
     134           3 :     return check(key, 0, true, trusted != null ? new HashSet<>() : null);
     135             :   }
     136             : 
     137             :   /**
     138             :    * Perform custom checks.
     139             :    *
     140             :    * <p>Default implementation reports no problems, but may be overridden by subclasses.
     141             :    *
     142             :    * @param key the public key.
     143             :    * @param depth the depth from the initial key passed to {@link #check( PGPPublicKey)}: 0 if this
     144             :    *     was the initial key, up to a maximum of {@code maxTrustDepth}.
     145             :    * @return the result of the custom check.
     146             :    */
     147             :   public CheckResult checkCustom(PGPPublicKey key, int depth) {
     148           1 :     return CheckResult.ok();
     149             :   }
     150             : 
     151             :   private CheckResult check(PGPPublicKey key, int depth, boolean expand, Set<Fingerprint> seen) {
     152           3 :     CheckResult basicResult = checkBasic(key, effectiveTime);
     153           3 :     CheckResult customResult = checkCustom(key, depth);
     154           3 :     CheckResult trustResult = checkWebOfTrust(key, store, depth, seen);
     155           3 :     if (!expand && !trustResult.isTrusted()) {
     156           1 :       trustResult = CheckResult.create(trustResult.getStatus(), "Key is not trusted");
     157             :     }
     158             : 
     159           3 :     List<String> problems =
     160             :         new ArrayList<>(
     161           3 :             basicResult.getProblems().size()
     162           3 :                 + customResult.getProblems().size()
     163           3 :                 + trustResult.getProblems().size());
     164           3 :     problems.addAll(basicResult.getProblems());
     165           3 :     problems.addAll(customResult.getProblems());
     166           3 :     problems.addAll(trustResult.getProblems());
     167             : 
     168             :     Status status;
     169           3 :     if (basicResult.getStatus() == BAD
     170           3 :         || customResult.getStatus() == BAD
     171           3 :         || trustResult.getStatus() == BAD) {
     172             :       // Any BAD result and the final result is BAD.
     173           1 :       status = BAD;
     174           3 :     } else if (trustResult.getStatus() == TRUSTED) {
     175             :       // basicResult is BAD or OK, whereas trustResult is BAD or TRUSTED. If
     176             :       // TRUSTED, we trust the final result.
     177           3 :       status = TRUSTED;
     178             :     } else {
     179             :       // All results were OK or better, but trustResult was not TRUSTED. Don't
     180             :       // let subclasses bypass checkWebOfTrust by returning TRUSTED; just return
     181             :       // OK here.
     182           1 :       status = OK;
     183             :     }
     184           3 :     return CheckResult.create(status, problems);
     185             :   }
     186             : 
     187             :   private CheckResult checkBasic(PGPPublicKey key, Instant now) {
     188           3 :     List<String> problems = new ArrayList<>(2);
     189           3 :     gatherRevocationProblems(key, now, problems);
     190             : 
     191           3 :     long validMs = key.getValidSeconds() * 1000;
     192           3 :     if (validMs != 0) {
     193           2 :       long msSinceCreation = now.toEpochMilli() - getCreationTime(key).toEpochMilli();
     194           2 :       if (msSinceCreation > validMs) {
     195           1 :         problems.add("Key is expired");
     196             :       }
     197             :     }
     198           3 :     return CheckResult.create(problems);
     199             :   }
     200             : 
     201             :   private void gatherRevocationProblems(PGPPublicKey key, Instant now, List<String> problems) {
     202             :     try {
     203           3 :       List<PGPSignature> revocations = new ArrayList<>();
     204           3 :       Map<Long, RevocationKey> revokers = new HashMap<>();
     205           3 :       PGPSignature selfRevocation = scanRevocations(key, now, revocations, revokers);
     206           3 :       if (selfRevocation != null) {
     207           1 :         RevocationReason reason = getRevocationReason(selfRevocation);
     208           1 :         if (isRevocationValid(selfRevocation, reason, now)) {
     209           1 :           problems.add(reasonToString(reason));
     210             :         }
     211           1 :       } else {
     212           3 :         checkRevocations(key, revocations, revokers, problems);
     213             :       }
     214           0 :     } catch (PGPException | IOException e) {
     215           0 :       problems.add("Error checking key revocation");
     216           3 :     }
     217           3 :   }
     218             : 
     219             :   private static boolean isRevocationValid(
     220             :       PGPSignature revocation, RevocationReason reason, Instant now) {
     221             :     // RFC4880 states:
     222             :     // "If a key has been revoked because of a compromise, all signatures
     223             :     // created by that key are suspect. However, if it was merely superseded or
     224             :     // retired, old signatures are still valid."
     225             :     //
     226             :     // Note that GnuPG does not implement this correctly, as it does not
     227             :     // consider the revocation reason and timestamp when checking whether a
     228             :     // signature (data or certification) is valid.
     229           1 :     return reason.getRevocationReason() == KEY_COMPROMISED
     230           1 :         || PushCertificateChecker.getCreationTime(revocation).isBefore(now);
     231             :   }
     232             : 
     233             :   @Nullable
     234             :   private PGPSignature scanRevocations(
     235             :       PGPPublicKey key,
     236             :       Instant now,
     237             :       List<PGPSignature> revocations,
     238             :       Map<Long, RevocationKey> revokers)
     239             :       throws PGPException {
     240             :     @SuppressWarnings("unchecked")
     241           3 :     Iterator<PGPSignature> allSigs = key.getSignatures();
     242           3 :     while (allSigs.hasNext()) {
     243           3 :       PGPSignature sig = allSigs.next();
     244           3 :       switch (sig.getSignatureType()) {
     245             :         case KEY_REVOCATION:
     246           1 :           if (sig.getKeyID() == key.getKeyID()) {
     247           1 :             sig.init(new BcPGPContentVerifierBuilderProvider(), key);
     248           1 :             if (sig.verifyCertification(key)) {
     249           1 :               return sig;
     250             :             }
     251             :           } else {
     252           1 :             RevocationReason reason = getRevocationReason(sig);
     253           1 :             if (reason != null && isRevocationValid(sig, reason, now)) {
     254           1 :               revocations.add(sig);
     255             :             }
     256             :           }
     257           1 :           break;
     258             :         case DIRECT_KEY:
     259           1 :           RevocationKey r = getRevocationKey(key, sig);
     260           1 :           if (r != null) {
     261           1 :             revokers.put(Fingerprint.getId(r.getFingerprint()), r);
     262             :           }
     263             :           break;
     264             :       }
     265           3 :     }
     266           3 :     return null;
     267             :   }
     268             : 
     269             :   @Nullable
     270             :   private RevocationKey getRevocationKey(PGPPublicKey key, PGPSignature sig) throws PGPException {
     271           1 :     if (sig.getKeyID() != key.getKeyID()) {
     272           0 :       return null;
     273             :     }
     274           1 :     SignatureSubpacket sub = sig.getHashedSubPackets().getSubpacket(REVOCATION_KEY);
     275           1 :     if (sub == null) {
     276           0 :       return null;
     277             :     }
     278           1 :     sig.init(new BcPGPContentVerifierBuilderProvider(), key);
     279           1 :     if (!sig.verifyCertification(key)) {
     280           0 :       return null;
     281             :     }
     282             : 
     283           1 :     return new RevocationKey(sub.isCritical(), sub.isLongLength(), sub.getData());
     284             :   }
     285             : 
     286             :   private void checkRevocations(
     287             :       PGPPublicKey key,
     288             :       List<PGPSignature> revocations,
     289             :       Map<Long, RevocationKey> revokers,
     290             :       List<String> problems)
     291             :       throws PGPException, IOException {
     292           3 :     for (PGPSignature revocation : revocations) {
     293           1 :       RevocationKey revoker = revokers.get(revocation.getKeyID());
     294           1 :       if (revoker == null) {
     295           1 :         continue; // Not a designated revoker.
     296             :       }
     297           1 :       byte[] rfp = revoker.getFingerprint();
     298           1 :       PGPPublicKeyRing revokerKeyRing = store.get(rfp);
     299           1 :       if (revokerKeyRing == null) {
     300             :         // Revoker is authorized and there is a revocation signature by this
     301             :         // revoker, but the key is not in the store so we can't verify the
     302             :         // signature.
     303           1 :         logger.atInfo().log(
     304             :             "Key %s is revoked by %s, which is not in the store. Assuming revocation is valid.",
     305           1 :             lazy(() -> Fingerprint.toString(key.getFingerprint())),
     306           1 :             lazy(() -> Fingerprint.toString(rfp)));
     307           1 :         problems.add(reasonToString(getRevocationReason(revocation)));
     308           1 :         continue;
     309             :       }
     310           1 :       PGPPublicKey rk = revokerKeyRing.getPublicKey();
     311           1 :       if (rk.getAlgorithm() != revoker.getAlgorithm()) {
     312           0 :         continue;
     313             :       }
     314           1 :       if (!checkBasic(rk, PushCertificateChecker.getCreationTime(revocation)).isOk()) {
     315             :         // Revoker's key was expired or revoked at time of revocation, so the
     316             :         // revocation is invalid.
     317           1 :         continue;
     318             :       }
     319           1 :       revocation.init(new BcPGPContentVerifierBuilderProvider(), rk);
     320           1 :       if (revocation.verifyCertification(key)) {
     321           1 :         problems.add(reasonToString(getRevocationReason(revocation)));
     322             :       }
     323           1 :     }
     324           3 :   }
     325             : 
     326             :   @Nullable
     327             :   private static RevocationReason getRevocationReason(PGPSignature sig) {
     328           1 :     if (sig.getSignatureType() != KEY_REVOCATION) {
     329           0 :       throw new IllegalArgumentException(
     330           0 :           "Expected KEY_REVOCATION signature, got " + sig.getSignatureType());
     331             :     }
     332           1 :     SignatureSubpacket sub = sig.getHashedSubPackets().getSubpacket(REVOCATION_REASON);
     333           1 :     if (sub == null) {
     334           0 :       return null;
     335             :     }
     336           1 :     return new RevocationReason(sub.isCritical(), sub.isLongLength(), sub.getData());
     337             :   }
     338             : 
     339             :   private static String reasonToString(RevocationReason reason) {
     340           1 :     StringBuilder r = new StringBuilder("Key is revoked (");
     341           1 :     if (reason == null) {
     342           0 :       return r.append("no reason provided)").toString();
     343             :     }
     344           1 :     switch (reason.getRevocationReason()) {
     345             :       case NO_REASON:
     346           0 :         r.append("no reason code specified");
     347           0 :         break;
     348             :       case KEY_SUPERSEDED:
     349           0 :         r.append("superseded");
     350           0 :         break;
     351             :       case KEY_COMPROMISED:
     352           1 :         r.append("key material has been compromised");
     353           1 :         break;
     354             :       case KEY_RETIRED:
     355           1 :         r.append("retired and no longer valid");
     356           1 :         break;
     357             :       default:
     358           0 :         r.append("reason code ").append(Integer.toString(reason.getRevocationReason())).append(')');
     359             :         break;
     360             :     }
     361           1 :     r.append(')');
     362           1 :     String desc = reason.getRevocationDescription();
     363           1 :     if (!desc.isEmpty()) {
     364           1 :       r.append(": ").append(desc);
     365             :     }
     366           1 :     return r.toString();
     367             :   }
     368             : 
     369             :   private CheckResult checkWebOfTrust(
     370             :       PGPPublicKey key, PublicKeyStore store, int depth, Set<Fingerprint> seen) {
     371           3 :     if (trusted == null) {
     372             :       // Trust checking not configured, server trusts all OK keys.
     373           3 :       return CheckResult.trusted();
     374             :     }
     375           1 :     Fingerprint fp = new Fingerprint(key.getFingerprint());
     376           1 :     if (seen.contains(fp)) {
     377           1 :       return CheckResult.ok("Key is trusted in a cycle");
     378             :     }
     379           1 :     seen.add(fp);
     380             : 
     381           1 :     Fingerprint trustedFp = trusted.get(key.getKeyID());
     382           1 :     if (trustedFp != null && trustedFp.equals(fp)) {
     383           1 :       return CheckResult.trusted(); // Directly trusted.
     384           1 :     } else if (depth >= maxTrustDepth) {
     385           1 :       return CheckResult.ok("No path of depth <= " + maxTrustDepth + " to a trusted key");
     386             :     }
     387             : 
     388           1 :     List<CheckResult> signerResults = new ArrayList<>();
     389           1 :     Iterator<String> userIds = key.getUserIDs();
     390           1 :     while (userIds.hasNext()) {
     391           1 :       String userId = userIds.next();
     392             : 
     393             :       // Don't check the timestamp of these certifications. This allows admins
     394             :       // to correct untrusted keys by signing them with a trusted key, such that
     395             :       // older signatures created by those keys retroactively appear valid.
     396           1 :       Iterator<PGPSignature> sigs = key.getSignaturesForID(userId);
     397             : 
     398           1 :       while (sigs.hasNext()) {
     399           1 :         PGPSignature sig = sigs.next();
     400             :         // TODO(dborowitz): Handle CERTIFICATION_REVOCATION.
     401           1 :         if (sig.getSignatureType() != PGPSignature.DEFAULT_CERTIFICATION
     402           1 :             && sig.getSignatureType() != PGPSignature.POSITIVE_CERTIFICATION) {
     403           0 :           continue; // Not a certification.
     404             :         }
     405             : 
     406           1 :         PGPPublicKey signer = getSigner(store, sig, userId, key, signerResults);
     407             :         // TODO(dborowitz): Require self certification.
     408           1 :         if (signer == null || Arrays.equals(signer.getFingerprint(), key.getFingerprint())) {
     409           1 :           continue;
     410             :         }
     411           1 :         String subpacketProblem = checkTrustSubpacket(sig, depth);
     412           1 :         if (subpacketProblem == null) {
     413           1 :           CheckResult signerResult = check(signer, depth + 1, false, seen);
     414           1 :           if (signerResult.isTrusted()) {
     415           1 :             return CheckResult.trusted();
     416             :           }
     417             :         }
     418           1 :         signerResults.add(
     419           1 :             CheckResult.ok(
     420           1 :                 "Certification by " + keyToString(signer) + " is valid, but key is not trusted"));
     421           1 :       }
     422           1 :     }
     423             : 
     424           1 :     List<String> problems = new ArrayList<>();
     425           1 :     problems.add("No path to a trusted key");
     426           1 :     for (CheckResult signerResult : signerResults) {
     427           1 :       problems.addAll(signerResult.getProblems());
     428           1 :     }
     429           1 :     return CheckResult.create(OK, problems);
     430             :   }
     431             : 
     432             :   @Nullable
     433             :   private static PGPPublicKey getSigner(
     434             :       PublicKeyStore store,
     435             :       PGPSignature sig,
     436             :       String userId,
     437             :       PGPPublicKey key,
     438             :       List<CheckResult> results) {
     439             :     try {
     440           1 :       PGPPublicKeyRingCollection signers = store.get(sig.getKeyID());
     441           1 :       if (!signers.getKeyRings().hasNext()) {
     442           1 :         results.add(
     443           1 :             CheckResult.ok(
     444             :                 "Key "
     445           1 :                     + keyIdToString(sig.getKeyID())
     446             :                     + " used for certification is not in store"));
     447           1 :         return null;
     448             :       }
     449           1 :       PGPPublicKey signer = PublicKeyStore.getSigner(signers, sig, userId, key);
     450           1 :       if (signer == null) {
     451           0 :         results.add(
     452           0 :             CheckResult.ok("Certification by " + keyIdToString(sig.getKeyID()) + " is not valid"));
     453           0 :         return null;
     454             :       }
     455           1 :       return signer;
     456           0 :     } catch (PGPException | IOException e) {
     457           0 :       results.add(
     458           0 :           CheckResult.ok("Error checking certification by " + keyIdToString(sig.getKeyID())));
     459           0 :       return null;
     460             :     }
     461             :   }
     462             : 
     463             :   @Nullable
     464             :   private String checkTrustSubpacket(PGPSignature sig, int depth) {
     465           1 :     SignatureSubpacket trustSub =
     466           1 :         sig.getHashedSubPackets().getSubpacket(SignatureSubpacketTags.TRUST_SIG);
     467           1 :     if (trustSub == null || trustSub.getData().length != 2) {
     468           0 :       return "Certification is missing trust information";
     469             :     }
     470           1 :     byte amount = trustSub.getData()[1];
     471           1 :     if (amount < COMPLETE_TRUST) {
     472           0 :       return "Certification does not fully trust key";
     473             :     }
     474           1 :     byte level = trustSub.getData()[0];
     475           1 :     int required = depth + 1;
     476           1 :     if (level < required) {
     477           1 :       return "Certification trusts to depth " + level + ", but depth " + required + " is required";
     478             :     }
     479           1 :     return null;
     480             :   }
     481             : 
     482             :   @SuppressWarnings("JdkObsolete")
     483             :   private static Instant getCreationTime(PGPPublicKey key) {
     484           2 :     return key.getCreationTime().toInstant();
     485             :   }
     486             : }

Generated by: LCOV version 1.16+git.20220603.dfeb750