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;
16 :
17 : import static com.google.common.flogger.LazyArgs.lazy;
18 : import static com.google.gerrit.extensions.common.GpgKeyInfo.Status.BAD;
19 : import static com.google.gerrit.extensions.common.GpgKeyInfo.Status.OK;
20 : import static com.google.gerrit.extensions.common.GpgKeyInfo.Status.TRUSTED;
21 : import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
22 : import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
23 : import static org.bouncycastle.bcpg.SignatureSubpacketTags.REVOCATION_KEY;
24 : import static org.bouncycastle.bcpg.SignatureSubpacketTags.REVOCATION_REASON;
25 : import static org.bouncycastle.bcpg.sig.RevocationReasonTags.KEY_COMPROMISED;
26 : import static org.bouncycastle.bcpg.sig.RevocationReasonTags.KEY_RETIRED;
27 : import static org.bouncycastle.bcpg.sig.RevocationReasonTags.KEY_SUPERSEDED;
28 : import static org.bouncycastle.bcpg.sig.RevocationReasonTags.NO_REASON;
29 : import static org.bouncycastle.openpgp.PGPSignature.DIRECT_KEY;
30 : import static org.bouncycastle.openpgp.PGPSignature.KEY_REVOCATION;
31 :
32 : import com.google.common.flogger.FluentLogger;
33 : import com.google.gerrit.common.Nullable;
34 : import com.google.gerrit.extensions.common.GpgKeyInfo.Status;
35 : import java.io.IOException;
36 : import java.time.Instant;
37 : import java.util.ArrayList;
38 : import java.util.Arrays;
39 : import java.util.HashMap;
40 : import java.util.HashSet;
41 : import java.util.Iterator;
42 : import java.util.List;
43 : import java.util.Map;
44 : import java.util.Set;
45 : import org.bouncycastle.bcpg.SignatureSubpacket;
46 : import org.bouncycastle.bcpg.SignatureSubpacketTags;
47 : import org.bouncycastle.bcpg.sig.RevocationKey;
48 : import org.bouncycastle.bcpg.sig.RevocationReason;
49 : import org.bouncycastle.openpgp.PGPException;
50 : import org.bouncycastle.openpgp.PGPPublicKey;
51 : import org.bouncycastle.openpgp.PGPPublicKeyRing;
52 : import org.bouncycastle.openpgp.PGPPublicKeyRingCollection;
53 : import org.bouncycastle.openpgp.PGPSignature;
54 : import org.bouncycastle.openpgp.operator.bc.BcPGPContentVerifierBuilderProvider;
55 :
56 : /** Checker for GPG public keys for use in a push certificate. */
57 3 : public class PublicKeyChecker {
58 3 : private static final FluentLogger logger = FluentLogger.forEnclosingClass();
59 :
60 : // https://tools.ietf.org/html/rfc4880#section-5.2.3.13
61 : private static final int COMPLETE_TRUST = 120;
62 :
63 : private PublicKeyStore store;
64 : private Map<Long, Fingerprint> trusted;
65 : private int maxTrustDepth;
66 3 : private Instant effectiveTime = Instant.now();
67 :
68 : /**
69 : * Enable web-of-trust checks.
70 : *
71 : * <p>If enabled, a store must be set with {@link #setStore(PublicKeyStore)}. (These methods are
72 : * separate since the store is a closeable resource that may not be available when reading trusted
73 : * keys from a config.)
74 : *
75 : * @param maxTrustDepth maximum depth to search while looking for a trusted key.
76 : * @param trusted ultimately trusted key fingerprints, keyed by fingerprint; may not be empty. To
77 : * construct a map, see {@link Fingerprint#byId(Iterable)}.
78 : * @return a reference to this object.
79 : */
80 : public PublicKeyChecker enableTrust(int maxTrustDepth, Map<Long, Fingerprint> trusted) {
81 1 : if (maxTrustDepth <= 0) {
82 0 : throw new IllegalArgumentException("maxTrustDepth must be positive, got: " + maxTrustDepth);
83 : }
84 1 : if (trusted == null || trusted.isEmpty()) {
85 0 : throw new IllegalArgumentException("at least one trusted key is required");
86 : }
87 1 : this.maxTrustDepth = maxTrustDepth;
88 1 : this.trusted = trusted;
89 1 : return this;
90 : }
91 :
92 : /** Disable web-of-trust checks. */
93 : public PublicKeyChecker disableTrust() {
94 3 : trusted = null;
95 3 : return this;
96 : }
97 :
98 : /** Set the public key store for reading keys referenced in signatures. */
99 : public PublicKeyChecker setStore(PublicKeyStore store) {
100 3 : if (store == null) {
101 0 : throw new IllegalArgumentException("PublicKeyStore is required");
102 : }
103 3 : this.store = store;
104 3 : return this;
105 : }
106 :
107 : /**
108 : * Set the effective time for checking the key.
109 : *
110 : * <p>If set, check whether the key should be considered valid (e.g. unexpired) as of this time.
111 : *
112 : * @param effectiveTime effective time.
113 : * @return a reference to this object.
114 : */
115 : public PublicKeyChecker setEffectiveTime(Instant effectiveTime) {
116 1 : this.effectiveTime = effectiveTime;
117 1 : return this;
118 : }
119 :
120 : protected Instant getEffectiveTime() {
121 0 : return effectiveTime;
122 : }
123 :
124 : /**
125 : * Check a public key.
126 : *
127 : * @param key the public key.
128 : * @return the result of the check.
129 : */
130 : public final CheckResult check(PGPPublicKey key) {
131 3 : if (store == null) {
132 0 : throw new IllegalStateException("PublicKeyStore is required");
133 : }
134 3 : return check(key, 0, true, trusted != null ? new HashSet<>() : null);
135 : }
136 :
137 : /**
138 : * Perform custom checks.
139 : *
140 : * <p>Default implementation reports no problems, but may be overridden by subclasses.
141 : *
142 : * @param key the public key.
143 : * @param depth the depth from the initial key passed to {@link #check( PGPPublicKey)}: 0 if this
144 : * was the initial key, up to a maximum of {@code maxTrustDepth}.
145 : * @return the result of the custom check.
146 : */
147 : public CheckResult checkCustom(PGPPublicKey key, int depth) {
148 1 : return CheckResult.ok();
149 : }
150 :
151 : private CheckResult check(PGPPublicKey key, int depth, boolean expand, Set<Fingerprint> seen) {
152 3 : CheckResult basicResult = checkBasic(key, effectiveTime);
153 3 : CheckResult customResult = checkCustom(key, depth);
154 3 : CheckResult trustResult = checkWebOfTrust(key, store, depth, seen);
155 3 : if (!expand && !trustResult.isTrusted()) {
156 1 : trustResult = CheckResult.create(trustResult.getStatus(), "Key is not trusted");
157 : }
158 :
159 3 : List<String> problems =
160 : new ArrayList<>(
161 3 : basicResult.getProblems().size()
162 3 : + customResult.getProblems().size()
163 3 : + trustResult.getProblems().size());
164 3 : problems.addAll(basicResult.getProblems());
165 3 : problems.addAll(customResult.getProblems());
166 3 : problems.addAll(trustResult.getProblems());
167 :
168 : Status status;
169 3 : if (basicResult.getStatus() == BAD
170 3 : || customResult.getStatus() == BAD
171 3 : || trustResult.getStatus() == BAD) {
172 : // Any BAD result and the final result is BAD.
173 1 : status = BAD;
174 3 : } else if (trustResult.getStatus() == TRUSTED) {
175 : // basicResult is BAD or OK, whereas trustResult is BAD or TRUSTED. If
176 : // TRUSTED, we trust the final result.
177 3 : status = TRUSTED;
178 : } else {
179 : // All results were OK or better, but trustResult was not TRUSTED. Don't
180 : // let subclasses bypass checkWebOfTrust by returning TRUSTED; just return
181 : // OK here.
182 1 : status = OK;
183 : }
184 3 : return CheckResult.create(status, problems);
185 : }
186 :
187 : private CheckResult checkBasic(PGPPublicKey key, Instant now) {
188 3 : List<String> problems = new ArrayList<>(2);
189 3 : gatherRevocationProblems(key, now, problems);
190 :
191 3 : long validMs = key.getValidSeconds() * 1000;
192 3 : if (validMs != 0) {
193 2 : long msSinceCreation = now.toEpochMilli() - getCreationTime(key).toEpochMilli();
194 2 : if (msSinceCreation > validMs) {
195 1 : problems.add("Key is expired");
196 : }
197 : }
198 3 : return CheckResult.create(problems);
199 : }
200 :
201 : private void gatherRevocationProblems(PGPPublicKey key, Instant now, List<String> problems) {
202 : try {
203 3 : List<PGPSignature> revocations = new ArrayList<>();
204 3 : Map<Long, RevocationKey> revokers = new HashMap<>();
205 3 : PGPSignature selfRevocation = scanRevocations(key, now, revocations, revokers);
206 3 : if (selfRevocation != null) {
207 1 : RevocationReason reason = getRevocationReason(selfRevocation);
208 1 : if (isRevocationValid(selfRevocation, reason, now)) {
209 1 : problems.add(reasonToString(reason));
210 : }
211 1 : } else {
212 3 : checkRevocations(key, revocations, revokers, problems);
213 : }
214 0 : } catch (PGPException | IOException e) {
215 0 : problems.add("Error checking key revocation");
216 3 : }
217 3 : }
218 :
219 : private static boolean isRevocationValid(
220 : PGPSignature revocation, RevocationReason reason, Instant now) {
221 : // RFC4880 states:
222 : // "If a key has been revoked because of a compromise, all signatures
223 : // created by that key are suspect. However, if it was merely superseded or
224 : // retired, old signatures are still valid."
225 : //
226 : // Note that GnuPG does not implement this correctly, as it does not
227 : // consider the revocation reason and timestamp when checking whether a
228 : // signature (data or certification) is valid.
229 1 : return reason.getRevocationReason() == KEY_COMPROMISED
230 1 : || PushCertificateChecker.getCreationTime(revocation).isBefore(now);
231 : }
232 :
233 : @Nullable
234 : private PGPSignature scanRevocations(
235 : PGPPublicKey key,
236 : Instant now,
237 : List<PGPSignature> revocations,
238 : Map<Long, RevocationKey> revokers)
239 : throws PGPException {
240 : @SuppressWarnings("unchecked")
241 3 : Iterator<PGPSignature> allSigs = key.getSignatures();
242 3 : while (allSigs.hasNext()) {
243 3 : PGPSignature sig = allSigs.next();
244 3 : switch (sig.getSignatureType()) {
245 : case KEY_REVOCATION:
246 1 : if (sig.getKeyID() == key.getKeyID()) {
247 1 : sig.init(new BcPGPContentVerifierBuilderProvider(), key);
248 1 : if (sig.verifyCertification(key)) {
249 1 : return sig;
250 : }
251 : } else {
252 1 : RevocationReason reason = getRevocationReason(sig);
253 1 : if (reason != null && isRevocationValid(sig, reason, now)) {
254 1 : revocations.add(sig);
255 : }
256 : }
257 1 : break;
258 : case DIRECT_KEY:
259 1 : RevocationKey r = getRevocationKey(key, sig);
260 1 : if (r != null) {
261 1 : revokers.put(Fingerprint.getId(r.getFingerprint()), r);
262 : }
263 : break;
264 : }
265 3 : }
266 3 : return null;
267 : }
268 :
269 : @Nullable
270 : private RevocationKey getRevocationKey(PGPPublicKey key, PGPSignature sig) throws PGPException {
271 1 : if (sig.getKeyID() != key.getKeyID()) {
272 0 : return null;
273 : }
274 1 : SignatureSubpacket sub = sig.getHashedSubPackets().getSubpacket(REVOCATION_KEY);
275 1 : if (sub == null) {
276 0 : return null;
277 : }
278 1 : sig.init(new BcPGPContentVerifierBuilderProvider(), key);
279 1 : if (!sig.verifyCertification(key)) {
280 0 : return null;
281 : }
282 :
283 1 : return new RevocationKey(sub.isCritical(), sub.isLongLength(), sub.getData());
284 : }
285 :
286 : private void checkRevocations(
287 : PGPPublicKey key,
288 : List<PGPSignature> revocations,
289 : Map<Long, RevocationKey> revokers,
290 : List<String> problems)
291 : throws PGPException, IOException {
292 3 : for (PGPSignature revocation : revocations) {
293 1 : RevocationKey revoker = revokers.get(revocation.getKeyID());
294 1 : if (revoker == null) {
295 1 : continue; // Not a designated revoker.
296 : }
297 1 : byte[] rfp = revoker.getFingerprint();
298 1 : PGPPublicKeyRing revokerKeyRing = store.get(rfp);
299 1 : if (revokerKeyRing == null) {
300 : // Revoker is authorized and there is a revocation signature by this
301 : // revoker, but the key is not in the store so we can't verify the
302 : // signature.
303 1 : logger.atInfo().log(
304 : "Key %s is revoked by %s, which is not in the store. Assuming revocation is valid.",
305 1 : lazy(() -> Fingerprint.toString(key.getFingerprint())),
306 1 : lazy(() -> Fingerprint.toString(rfp)));
307 1 : problems.add(reasonToString(getRevocationReason(revocation)));
308 1 : continue;
309 : }
310 1 : PGPPublicKey rk = revokerKeyRing.getPublicKey();
311 1 : if (rk.getAlgorithm() != revoker.getAlgorithm()) {
312 0 : continue;
313 : }
314 1 : if (!checkBasic(rk, PushCertificateChecker.getCreationTime(revocation)).isOk()) {
315 : // Revoker's key was expired or revoked at time of revocation, so the
316 : // revocation is invalid.
317 1 : continue;
318 : }
319 1 : revocation.init(new BcPGPContentVerifierBuilderProvider(), rk);
320 1 : if (revocation.verifyCertification(key)) {
321 1 : problems.add(reasonToString(getRevocationReason(revocation)));
322 : }
323 1 : }
324 3 : }
325 :
326 : @Nullable
327 : private static RevocationReason getRevocationReason(PGPSignature sig) {
328 1 : if (sig.getSignatureType() != KEY_REVOCATION) {
329 0 : throw new IllegalArgumentException(
330 0 : "Expected KEY_REVOCATION signature, got " + sig.getSignatureType());
331 : }
332 1 : SignatureSubpacket sub = sig.getHashedSubPackets().getSubpacket(REVOCATION_REASON);
333 1 : if (sub == null) {
334 0 : return null;
335 : }
336 1 : return new RevocationReason(sub.isCritical(), sub.isLongLength(), sub.getData());
337 : }
338 :
339 : private static String reasonToString(RevocationReason reason) {
340 1 : StringBuilder r = new StringBuilder("Key is revoked (");
341 1 : if (reason == null) {
342 0 : return r.append("no reason provided)").toString();
343 : }
344 1 : switch (reason.getRevocationReason()) {
345 : case NO_REASON:
346 0 : r.append("no reason code specified");
347 0 : break;
348 : case KEY_SUPERSEDED:
349 0 : r.append("superseded");
350 0 : break;
351 : case KEY_COMPROMISED:
352 1 : r.append("key material has been compromised");
353 1 : break;
354 : case KEY_RETIRED:
355 1 : r.append("retired and no longer valid");
356 1 : break;
357 : default:
358 0 : r.append("reason code ").append(Integer.toString(reason.getRevocationReason())).append(')');
359 : break;
360 : }
361 1 : r.append(')');
362 1 : String desc = reason.getRevocationDescription();
363 1 : if (!desc.isEmpty()) {
364 1 : r.append(": ").append(desc);
365 : }
366 1 : return r.toString();
367 : }
368 :
369 : private CheckResult checkWebOfTrust(
370 : PGPPublicKey key, PublicKeyStore store, int depth, Set<Fingerprint> seen) {
371 3 : if (trusted == null) {
372 : // Trust checking not configured, server trusts all OK keys.
373 3 : return CheckResult.trusted();
374 : }
375 1 : Fingerprint fp = new Fingerprint(key.getFingerprint());
376 1 : if (seen.contains(fp)) {
377 1 : return CheckResult.ok("Key is trusted in a cycle");
378 : }
379 1 : seen.add(fp);
380 :
381 1 : Fingerprint trustedFp = trusted.get(key.getKeyID());
382 1 : if (trustedFp != null && trustedFp.equals(fp)) {
383 1 : return CheckResult.trusted(); // Directly trusted.
384 1 : } else if (depth >= maxTrustDepth) {
385 1 : return CheckResult.ok("No path of depth <= " + maxTrustDepth + " to a trusted key");
386 : }
387 :
388 1 : List<CheckResult> signerResults = new ArrayList<>();
389 1 : Iterator<String> userIds = key.getUserIDs();
390 1 : while (userIds.hasNext()) {
391 1 : String userId = userIds.next();
392 :
393 : // Don't check the timestamp of these certifications. This allows admins
394 : // to correct untrusted keys by signing them with a trusted key, such that
395 : // older signatures created by those keys retroactively appear valid.
396 1 : Iterator<PGPSignature> sigs = key.getSignaturesForID(userId);
397 :
398 1 : while (sigs.hasNext()) {
399 1 : PGPSignature sig = sigs.next();
400 : // TODO(dborowitz): Handle CERTIFICATION_REVOCATION.
401 1 : if (sig.getSignatureType() != PGPSignature.DEFAULT_CERTIFICATION
402 1 : && sig.getSignatureType() != PGPSignature.POSITIVE_CERTIFICATION) {
403 0 : continue; // Not a certification.
404 : }
405 :
406 1 : PGPPublicKey signer = getSigner(store, sig, userId, key, signerResults);
407 : // TODO(dborowitz): Require self certification.
408 1 : if (signer == null || Arrays.equals(signer.getFingerprint(), key.getFingerprint())) {
409 1 : continue;
410 : }
411 1 : String subpacketProblem = checkTrustSubpacket(sig, depth);
412 1 : if (subpacketProblem == null) {
413 1 : CheckResult signerResult = check(signer, depth + 1, false, seen);
414 1 : if (signerResult.isTrusted()) {
415 1 : return CheckResult.trusted();
416 : }
417 : }
418 1 : signerResults.add(
419 1 : CheckResult.ok(
420 1 : "Certification by " + keyToString(signer) + " is valid, but key is not trusted"));
421 1 : }
422 1 : }
423 :
424 1 : List<String> problems = new ArrayList<>();
425 1 : problems.add("No path to a trusted key");
426 1 : for (CheckResult signerResult : signerResults) {
427 1 : problems.addAll(signerResult.getProblems());
428 1 : }
429 1 : return CheckResult.create(OK, problems);
430 : }
431 :
432 : @Nullable
433 : private static PGPPublicKey getSigner(
434 : PublicKeyStore store,
435 : PGPSignature sig,
436 : String userId,
437 : PGPPublicKey key,
438 : List<CheckResult> results) {
439 : try {
440 1 : PGPPublicKeyRingCollection signers = store.get(sig.getKeyID());
441 1 : if (!signers.getKeyRings().hasNext()) {
442 1 : results.add(
443 1 : CheckResult.ok(
444 : "Key "
445 1 : + keyIdToString(sig.getKeyID())
446 : + " used for certification is not in store"));
447 1 : return null;
448 : }
449 1 : PGPPublicKey signer = PublicKeyStore.getSigner(signers, sig, userId, key);
450 1 : if (signer == null) {
451 0 : results.add(
452 0 : CheckResult.ok("Certification by " + keyIdToString(sig.getKeyID()) + " is not valid"));
453 0 : return null;
454 : }
455 1 : return signer;
456 0 : } catch (PGPException | IOException e) {
457 0 : results.add(
458 0 : CheckResult.ok("Error checking certification by " + keyIdToString(sig.getKeyID())));
459 0 : return null;
460 : }
461 : }
462 :
463 : @Nullable
464 : private String checkTrustSubpacket(PGPSignature sig, int depth) {
465 1 : SignatureSubpacket trustSub =
466 1 : sig.getHashedSubPackets().getSubpacket(SignatureSubpacketTags.TRUST_SIG);
467 1 : if (trustSub == null || trustSub.getData().length != 2) {
468 0 : return "Certification is missing trust information";
469 : }
470 1 : byte amount = trustSub.getData()[1];
471 1 : if (amount < COMPLETE_TRUST) {
472 0 : return "Certification does not fully trust key";
473 : }
474 1 : byte level = trustSub.getData()[0];
475 1 : int required = depth + 1;
476 1 : if (level < required) {
477 1 : return "Certification trusts to depth " + level + ", but depth " + required + " is required";
478 : }
479 1 : return null;
480 : }
481 :
482 : @SuppressWarnings("JdkObsolete")
483 : private static Instant getCreationTime(PGPPublicKey key) {
484 2 : return key.getCreationTime().toInstant();
485 : }
486 : }
|