<filename>java/com/google/gerrit/gpg/PublicKeyChecker.java
// 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();
  }
}
