LCOV - code coverage report
Current view: top level - gpg/server - PostGpgKeys.java (source / functions) Hit Total Coverage
Test: _coverage_report.dat Lines: 119 144 82.6 %
Date: 2022-11-19 15:00:39 Functions: 12 13 92.3 %

          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.server;
      16             : 
      17             : import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
      18             : import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
      19             : import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
      20             : import static java.nio.charset.StandardCharsets.UTF_8;
      21             : import static java.util.stream.Collectors.joining;
      22             : import static java.util.stream.Collectors.toList;
      23             : 
      24             : import com.google.common.base.Joiner;
      25             : import com.google.common.base.Throwables;
      26             : import com.google.common.collect.ImmutableList;
      27             : import com.google.common.collect.ImmutableMap;
      28             : import com.google.common.collect.Lists;
      29             : import com.google.common.collect.Maps;
      30             : import com.google.common.flogger.FluentLogger;
      31             : import com.google.common.io.BaseEncoding;
      32             : import com.google.gerrit.common.Nullable;
      33             : import com.google.gerrit.entities.Account;
      34             : import com.google.gerrit.exceptions.EmailException;
      35             : import com.google.gerrit.exceptions.StorageException;
      36             : import com.google.gerrit.extensions.api.accounts.GpgKeysInput;
      37             : import com.google.gerrit.extensions.common.GpgKeyInfo;
      38             : import com.google.gerrit.extensions.restapi.BadRequestException;
      39             : import com.google.gerrit.extensions.restapi.ResourceConflictException;
      40             : import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
      41             : import com.google.gerrit.extensions.restapi.Response;
      42             : import com.google.gerrit.extensions.restapi.RestApiException;
      43             : import com.google.gerrit.extensions.restapi.RestModifyView;
      44             : import com.google.gerrit.gpg.CheckResult;
      45             : import com.google.gerrit.gpg.Fingerprint;
      46             : import com.google.gerrit.gpg.GerritPublicKeyChecker;
      47             : import com.google.gerrit.gpg.PublicKeyChecker;
      48             : import com.google.gerrit.gpg.PublicKeyStore;
      49             : import com.google.gerrit.server.CurrentUser;
      50             : import com.google.gerrit.server.GerritPersonIdent;
      51             : import com.google.gerrit.server.IdentifiedUser;
      52             : import com.google.gerrit.server.UserInitiated;
      53             : import com.google.gerrit.server.account.AccountResource;
      54             : import com.google.gerrit.server.account.AccountState;
      55             : import com.google.gerrit.server.account.AccountsUpdate;
      56             : import com.google.gerrit.server.account.externalids.ExternalId;
      57             : import com.google.gerrit.server.account.externalids.ExternalIdFactory;
      58             : import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
      59             : import com.google.gerrit.server.account.externalids.ExternalIds;
      60             : import com.google.gerrit.server.mail.send.AddKeySender;
      61             : import com.google.gerrit.server.mail.send.DeleteKeySender;
      62             : import com.google.gerrit.server.query.account.InternalAccountQuery;
      63             : import com.google.gerrit.server.update.RetryHelper;
      64             : import com.google.inject.Inject;
      65             : import com.google.inject.Provider;
      66             : import com.google.inject.Singleton;
      67             : import java.io.ByteArrayInputStream;
      68             : import java.io.IOException;
      69             : import java.io.InputStream;
      70             : import java.util.ArrayList;
      71             : import java.util.Collection;
      72             : import java.util.List;
      73             : import java.util.Map;
      74             : import org.bouncycastle.bcpg.ArmoredInputStream;
      75             : import org.bouncycastle.openpgp.PGPException;
      76             : import org.bouncycastle.openpgp.PGPPublicKey;
      77             : import org.bouncycastle.openpgp.PGPPublicKeyRing;
      78             : import org.bouncycastle.openpgp.PGPRuntimeOperationException;
      79             : import org.bouncycastle.openpgp.bc.BcPGPObjectFactory;
      80             : import org.eclipse.jgit.errors.ConfigInvalidException;
      81             : import org.eclipse.jgit.lib.CommitBuilder;
      82             : import org.eclipse.jgit.lib.PersonIdent;
      83             : import org.eclipse.jgit.lib.RefUpdate;
      84             : 
      85             : @Singleton
      86             : public class PostGpgKeys implements RestModifyView<AccountResource, GpgKeysInput> {
      87           7 :   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
      88             : 
      89             :   private final Provider<PersonIdent> serverIdent;
      90             :   private final Provider<CurrentUser> self;
      91             :   private final Provider<PublicKeyStore> storeProvider;
      92             :   private final GerritPublicKeyChecker.Factory checkerFactory;
      93             :   private final AddKeySender.Factory addKeySenderFactory;
      94             :   private final DeleteKeySender.Factory deleteKeySenderFactory;
      95             :   private final Provider<InternalAccountQuery> accountQueryProvider;
      96             :   private final ExternalIds externalIds;
      97             :   private final Provider<AccountsUpdate> accountsUpdateProvider;
      98             :   private final RetryHelper retryHelper;
      99             :   private final ExternalIdFactory externalIdFactory;
     100             :   private final ExternalIdKeyFactory externalIdKeyFactory;
     101             : 
     102             :   @Inject
     103             :   PostGpgKeys(
     104             :       @GerritPersonIdent Provider<PersonIdent> serverIdent,
     105             :       Provider<CurrentUser> self,
     106             :       Provider<PublicKeyStore> storeProvider,
     107             :       GerritPublicKeyChecker.Factory checkerFactory,
     108             :       AddKeySender.Factory addKeySenderFactory,
     109             :       DeleteKeySender.Factory deleteKeySenderFactory,
     110             :       Provider<InternalAccountQuery> accountQueryProvider,
     111             :       ExternalIds externalIds,
     112             :       @UserInitiated Provider<AccountsUpdate> accountsUpdateProvider,
     113             :       RetryHelper retryHelper,
     114             :       ExternalIdFactory externalIdFactory,
     115           7 :       ExternalIdKeyFactory externalIdKeyFactory) {
     116           7 :     this.serverIdent = serverIdent;
     117           7 :     this.self = self;
     118           7 :     this.storeProvider = storeProvider;
     119           7 :     this.checkerFactory = checkerFactory;
     120           7 :     this.addKeySenderFactory = addKeySenderFactory;
     121           7 :     this.deleteKeySenderFactory = deleteKeySenderFactory;
     122           7 :     this.accountQueryProvider = accountQueryProvider;
     123           7 :     this.externalIds = externalIds;
     124           7 :     this.accountsUpdateProvider = accountsUpdateProvider;
     125           7 :     this.retryHelper = retryHelper;
     126           7 :     this.externalIdFactory = externalIdFactory;
     127           7 :     this.externalIdKeyFactory = externalIdKeyFactory;
     128           7 :   }
     129             : 
     130             :   @Override
     131             :   public Response<Map<String, GpgKeyInfo>> apply(AccountResource rsrc, GpgKeysInput input)
     132             :       throws RestApiException, PGPException, IOException, ConfigInvalidException {
     133           2 :     GpgKeys.checkVisible(self, rsrc);
     134             : 
     135           2 :     Collection<ExternalId> existingExtIds =
     136           2 :         externalIds.byAccount(rsrc.getUser().getAccountId(), SCHEME_GPGKEY);
     137           2 :     try (PublicKeyStore store = storeProvider.get()) {
     138           2 :       Map<ExternalId, Fingerprint> toRemove = readKeysToRemove(input, existingExtIds);
     139           2 :       Collection<Fingerprint> fingerprintsToRemove = toRemove.values();
     140           2 :       List<PGPPublicKeyRing> newKeys = readKeysToAdd(input, fingerprintsToRemove);
     141           2 :       List<ExternalId> newExtIds = new ArrayList<>(existingExtIds.size());
     142             : 
     143           2 :       for (PGPPublicKeyRing keyRing : newKeys) {
     144           2 :         PGPPublicKey key = keyRing.getPublicKey();
     145           2 :         ExternalId.Key extIdKey = toExtIdKey(key.getFingerprint());
     146           2 :         Account account = getAccountByExternalId(extIdKey);
     147           2 :         if (account != null) {
     148           1 :           if (!account.id().equals(rsrc.getUser().getAccountId())) {
     149           1 :             throw new ResourceConflictException("GPG key already associated with another account");
     150             :           }
     151             :         } else {
     152           2 :           newExtIds.add(externalIdFactory.create(extIdKey, rsrc.getUser().getAccountId()));
     153             :         }
     154           2 :       }
     155             : 
     156           2 :       storeKeys(rsrc, newKeys, fingerprintsToRemove);
     157             : 
     158           2 :       accountsUpdateProvider
     159           2 :           .get()
     160           2 :           .update(
     161             :               "Update GPG Keys via API",
     162           2 :               rsrc.getUser().getAccountId(),
     163           2 :               u -> u.replaceExternalIds(toRemove.keySet(), newExtIds));
     164           2 :       return Response.ok(toJson(newKeys, fingerprintsToRemove, store, rsrc.getUser()));
     165             :     }
     166             :   }
     167             : 
     168             :   private ImmutableMap<ExternalId, Fingerprint> readKeysToRemove(
     169             :       GpgKeysInput input, Collection<ExternalId> existingExtIds) {
     170           2 :     if (input.delete == null || input.delete.isEmpty()) {
     171           2 :       return ImmutableMap.of();
     172             :     }
     173           1 :     Map<ExternalId, Fingerprint> fingerprints =
     174           1 :         Maps.newHashMapWithExpectedSize(input.delete.size());
     175           1 :     for (String id : input.delete) {
     176             :       try {
     177           1 :         ExternalId gpgKeyExtId = GpgKeys.findGpgKey(id, existingExtIds);
     178           1 :         fingerprints.put(gpgKeyExtId, new Fingerprint(GpgKeys.parseFingerprint(gpgKeyExtId)));
     179           1 :       } catch (ResourceNotFoundException e) {
     180             :         // Skip removal.
     181           1 :       }
     182           1 :     }
     183           1 :     return ImmutableMap.copyOf(fingerprints);
     184             :   }
     185             : 
     186             :   private ImmutableList<PGPPublicKeyRing> readKeysToAdd(
     187             :       GpgKeysInput input, Collection<Fingerprint> toRemove)
     188             :       throws BadRequestException, IOException {
     189           2 :     if (input.add == null || input.add.isEmpty()) {
     190           0 :       return ImmutableList.of();
     191             :     }
     192           2 :     List<PGPPublicKeyRing> keyRings = new ArrayList<>(input.add.size());
     193           2 :     for (String armored : input.add) {
     194           2 :       try (InputStream in = new ByteArrayInputStream(armored.getBytes(UTF_8));
     195           2 :           ArmoredInputStream ain = new ArmoredInputStream(in)) {
     196             :         @SuppressWarnings("unchecked")
     197           2 :         List<Object> objs = Lists.newArrayList(new BcPGPObjectFactory(ain));
     198           2 :         if (objs.size() != 1 || !(objs.get(0) instanceof PGPPublicKeyRing)) {
     199           0 :           throw new BadRequestException("Expected exactly one PUBLIC KEY BLOCK");
     200             :         }
     201           2 :         PGPPublicKeyRing keyRing = (PGPPublicKeyRing) objs.get(0);
     202           2 :         if (toRemove.contains(new Fingerprint(keyRing.getPublicKey().getFingerprint()))) {
     203           1 :           throw new BadRequestException(
     204           1 :               "Cannot both add and delete key: " + keyToString(keyRing.getPublicKey()));
     205             :         }
     206           2 :         keyRings.add(keyRing);
     207           1 :       } catch (PGPRuntimeOperationException e) {
     208           1 :         throw new BadRequestException("Failed to parse GPG keys", e);
     209           2 :       }
     210           2 :     }
     211           2 :     return ImmutableList.copyOf(keyRings);
     212             :   }
     213             : 
     214             :   private void storeKeys(
     215             :       AccountResource rsrc, List<PGPPublicKeyRing> keyRings, Collection<Fingerprint> toRemove)
     216             :       throws RestApiException, PGPException, IOException {
     217             :     try {
     218           2 :       retryHelper
     219           2 :           .accountUpdate("storeGpgKeys", () -> tryStoreKeys(rsrc, keyRings, toRemove))
     220           2 :           .call();
     221           0 :     } catch (Exception e) {
     222           0 :       Throwables.throwIfUnchecked(e);
     223           0 :       Throwables.throwIfInstanceOf(e, RestApiException.class);
     224           0 :       Throwables.throwIfInstanceOf(e, IOException.class);
     225           0 :       Throwables.throwIfInstanceOf(e, PGPException.class);
     226           0 :       throw new StorageException(e);
     227           2 :     }
     228           2 :   }
     229             : 
     230             :   private Void tryStoreKeys(
     231             :       AccountResource rsrc, List<PGPPublicKeyRing> keyRings, Collection<Fingerprint> toRemove)
     232             :       throws RestApiException, PGPException, IOException {
     233           2 :     try (PublicKeyStore store = storeProvider.get()) {
     234           2 :       List<String> addedKeys = new ArrayList<>();
     235           2 :       IdentifiedUser user = rsrc.getUser();
     236           2 :       for (PGPPublicKeyRing keyRing : keyRings) {
     237           2 :         PGPPublicKey key = keyRing.getPublicKey();
     238             :         // Don't check web of trust; admins can fill in certifications later.
     239           2 :         CheckResult result = checkerFactory.create(user, store).disableTrust().check(key);
     240           2 :         if (!result.isOk()) {
     241           0 :           throw new BadRequestException(
     242           0 :               String.format(
     243             :                   "Problems with public key %s:\n%s",
     244           0 :                   keyToString(key), Joiner.on('\n').join(result.getProblems())));
     245             :         }
     246           2 :         addedKeys.add(PublicKeyStore.keyToString(key));
     247           2 :         store.add(keyRing);
     248           2 :       }
     249           2 :       for (Fingerprint fp : toRemove) {
     250           1 :         store.remove(fp.get());
     251           1 :       }
     252           2 :       CommitBuilder cb = new CommitBuilder();
     253           2 :       PersonIdent committer = serverIdent.get();
     254           2 :       cb.setAuthor(user.newCommitterIdent(committer));
     255           2 :       cb.setCommitter(committer);
     256             : 
     257           2 :       RefUpdate.Result saveResult = store.save(cb);
     258           2 :       switch (saveResult) {
     259             :         case NEW:
     260             :         case FAST_FORWARD:
     261             :         case FORCED:
     262           2 :           if (!addedKeys.isEmpty()) {
     263             :             try {
     264           2 :               addKeySenderFactory.create(user, addedKeys).send();
     265           0 :             } catch (EmailException e) {
     266           0 :               logger.atSevere().withCause(e).log(
     267             :                   "Cannot send GPG key added message to %s",
     268           0 :                   rsrc.getUser().getAccount().preferredEmail());
     269           2 :             }
     270             :           }
     271           2 :           if (!toRemove.isEmpty()) {
     272             :             try {
     273           1 :               deleteKeySenderFactory
     274           1 :                   .create(user, toRemove.stream().map(Fingerprint::toString).collect(toList()))
     275           1 :                   .send();
     276           0 :             } catch (EmailException e) {
     277           0 :               logger.atSevere().withCause(e).log(
     278           0 :                   "Cannot send GPG key deleted message to %s", user.getAccount().preferredEmail());
     279           1 :             }
     280             :           }
     281             :           break;
     282             :         case NO_CHANGE:
     283           0 :           break;
     284             :         case LOCK_FAILURE:
     285             :         case IO_FAILURE:
     286             :         case NOT_ATTEMPTED:
     287             :         case REJECTED:
     288             :         case REJECTED_CURRENT_BRANCH:
     289             :         case RENAMED:
     290             :         case REJECTED_MISSING_OBJECT:
     291             :         case REJECTED_OTHER_REASON:
     292             :         default:
     293           0 :           throw new StorageException(String.format("Failed to save public keys: %s", saveResult));
     294             :       }
     295             :     }
     296           2 :     return null;
     297             :   }
     298             : 
     299             :   private ExternalId.Key toExtIdKey(byte[] fp) {
     300           2 :     return externalIdKeyFactory.create(SCHEME_GPGKEY, BaseEncoding.base16().encode(fp));
     301             :   }
     302             : 
     303             :   @Nullable
     304             :   private Account getAccountByExternalId(ExternalId.Key extIdKey) {
     305           2 :     List<AccountState> accountStates = accountQueryProvider.get().byExternalId(extIdKey);
     306             : 
     307           2 :     if (accountStates.isEmpty()) {
     308           2 :       return null;
     309             :     }
     310             : 
     311           1 :     if (accountStates.size() > 1) {
     312           0 :       String msg = "GPG key " + extIdKey.get() + " associated with multiple accounts: [";
     313           0 :       msg =
     314           0 :           accountStates.stream()
     315           0 :               .map(a -> a.account().id().toString())
     316           0 :               .collect(joining(", ", msg, "]"));
     317           0 :       throw new IllegalStateException(msg);
     318             :     }
     319             : 
     320           1 :     return accountStates.get(0).account();
     321             :   }
     322             : 
     323             :   private Map<String, GpgKeyInfo> toJson(
     324             :       Collection<PGPPublicKeyRing> keys,
     325             :       Collection<Fingerprint> deleted,
     326             :       PublicKeyStore store,
     327             :       IdentifiedUser user)
     328             :       throws IOException {
     329             :     // Unlike when storing keys, include web-of-trust checks when producing
     330             :     // result JSON, so the user at least knows of any issues.
     331           2 :     PublicKeyChecker checker = checkerFactory.create(user, store);
     332           2 :     Map<String, GpgKeyInfo> infos = Maps.newHashMapWithExpectedSize(keys.size() + deleted.size());
     333           2 :     for (PGPPublicKeyRing keyRing : keys) {
     334           2 :       PGPPublicKey key = keyRing.getPublicKey();
     335           2 :       CheckResult result = checker.check(key);
     336           2 :       GpgKeyInfo info = GpgKeys.toJson(key, result);
     337           2 :       infos.put(info.id, info);
     338           2 :       info.id = null;
     339           2 :     }
     340           2 :     for (Fingerprint fp : deleted) {
     341           1 :       infos.put(keyIdToString(fp.getId()), new GpgKeyInfo());
     342           1 :     }
     343           2 :     return infos;
     344             :   }
     345             : }

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