| // 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.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. | |
| */ | |
| 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); | |
| } | |
| @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(); | |
| } | |
| } |