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 : }
|