| // 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.errorprone.annotations.CanIgnoreReturnValue; |
| import com.google.gerrit.common.Nullable; |
| 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. |
| */ |
| @CanIgnoreReturnValue |
| 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. */ |
| @CanIgnoreReturnValue |
| public PublicKeyChecker disableTrust() { |
| trusted = null; |
| return this; |
| } |
| |
| /** Set the public key store for reading keys referenced in signatures. */ |
| @CanIgnoreReturnValue |
| 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. |
| */ |
| @CanIgnoreReturnValue |
| 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); |
| } |
| |
| @Nullable |
| private PGPSignature scanRevocations( |
| PGPPublicKey key, |
| Instant now, |
| List<PGPSignature> revocations, |
| Map<Long, RevocationKey> revokers) |
| throws PGPException { |
| 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; |
| } |
| |
| @Nullable |
| 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))); |
| } |
| } |
| } |
| |
| @Nullable |
| 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); |
| } |
| |
| @Nullable |
| 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; |
| } |
| } |
| |
| @Nullable |
| 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(); |
| } |
| } |