422 lines
14 KiB
Java
422 lines
14 KiB
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.base.Preconditions.checkState;
|
|
import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
|
|
|
|
import java.io.ByteArrayOutputStream;
|
|
import java.io.IOException;
|
|
import java.io.InputStream;
|
|
import java.util.ArrayList;
|
|
import java.util.Arrays;
|
|
import java.util.Collections;
|
|
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.ArmoredInputStream;
|
|
import org.bouncycastle.bcpg.ArmoredOutputStream;
|
|
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.bc.BcPGPObjectFactory;
|
|
import org.bouncycastle.openpgp.operator.bc.BcPGPContentVerifierBuilderProvider;
|
|
import org.eclipse.jgit.lib.CommitBuilder;
|
|
import org.eclipse.jgit.lib.Constants;
|
|
import org.eclipse.jgit.lib.ObjectId;
|
|
import org.eclipse.jgit.lib.ObjectInserter;
|
|
import org.eclipse.jgit.lib.ObjectReader;
|
|
import org.eclipse.jgit.lib.Ref;
|
|
import org.eclipse.jgit.lib.RefUpdate;
|
|
import org.eclipse.jgit.lib.Repository;
|
|
import org.eclipse.jgit.notes.Note;
|
|
import org.eclipse.jgit.notes.NoteMap;
|
|
import org.eclipse.jgit.revwalk.RevCommit;
|
|
import org.eclipse.jgit.revwalk.RevWalk;
|
|
import org.eclipse.jgit.util.NB;
|
|
|
|
/**
|
|
* Store of GPG public keys in git notes.
|
|
*
|
|
* <p>Keys are stored in filenames based on their hex key ID, padded out to 40 characters to match
|
|
* the length of a SHA-1. (This is to easily reuse existing fanout code in {@link NoteMap}, and may
|
|
* be changed later after an appropriate transition.)
|
|
*
|
|
* <p>The contents of each file is an ASCII armored stream containing one or more public key rings
|
|
* matching the ID. Multiple keys are supported because forging a key ID is possible, but such a key
|
|
* cannot be used to verify signatures produced with the correct key.
|
|
*
|
|
* <p>No additional checks are performed on the key after reading; callers should only trust keys
|
|
* after checking with a {@link PublicKeyChecker}.
|
|
*/
|
|
public class PublicKeyStore implements AutoCloseable {
|
|
private static final ObjectId EMPTY_TREE =
|
|
ObjectId.fromString("4b825dc642cb6eb9a060e54bf8d69288fbee4904");
|
|
|
|
/** Ref where GPG public keys are stored. */
|
|
public static final String REFS_GPG_KEYS = "refs/meta/gpg-keys";
|
|
|
|
/**
|
|
* Choose the public key that produced a signature.
|
|
*
|
|
* <p>
|
|
*
|
|
* @param keyRings candidate keys.
|
|
* @param sig signature object.
|
|
* @param data signed payload.
|
|
* @return the key chosen from {@code keyRings} that was able to verify the signature, or {@code
|
|
* null} if none was found.
|
|
* @throws PGPException if an error occurred verifying the signature.
|
|
*/
|
|
public static PGPPublicKey getSigner(
|
|
Iterable<PGPPublicKeyRing> keyRings, PGPSignature sig, byte[] data) throws PGPException {
|
|
for (PGPPublicKeyRing kr : keyRings) {
|
|
PGPPublicKey k = kr.getPublicKey();
|
|
sig.init(new BcPGPContentVerifierBuilderProvider(), k);
|
|
sig.update(data);
|
|
if (sig.verify()) {
|
|
return k;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Choose the public key that produced a certification.
|
|
*
|
|
* <p>
|
|
*
|
|
* @param keyRings candidate keys.
|
|
* @param sig signature object.
|
|
* @param userId user ID being certified.
|
|
* @param key key being certified.
|
|
* @return the key chosen from {@code keyRings} that was able to verify the certification, or
|
|
* {@code null} if none was found.
|
|
* @throws PGPException if an error occurred verifying the certification.
|
|
*/
|
|
public static PGPPublicKey getSigner(
|
|
Iterable<PGPPublicKeyRing> keyRings, PGPSignature sig, String userId, PGPPublicKey key)
|
|
throws PGPException {
|
|
for (PGPPublicKeyRing kr : keyRings) {
|
|
PGPPublicKey k = kr.getPublicKey();
|
|
sig.init(new BcPGPContentVerifierBuilderProvider(), k);
|
|
if (sig.verifyCertification(userId, key)) {
|
|
return k;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private final Repository repo;
|
|
private ObjectReader reader;
|
|
private RevCommit tip;
|
|
private NoteMap notes;
|
|
private Map<Fingerprint, PGPPublicKeyRing> toAdd;
|
|
private Set<Fingerprint> toRemove;
|
|
|
|
/** @param repo repository to read keys from. */
|
|
public PublicKeyStore(Repository repo) {
|
|
this.repo = repo;
|
|
toAdd = new HashMap<>();
|
|
toRemove = new HashSet<>();
|
|
}
|
|
|
|
@Override
|
|
public void close() {
|
|
reset();
|
|
}
|
|
|
|
private void reset() {
|
|
if (reader != null) {
|
|
reader.close();
|
|
reader = null;
|
|
notes = null;
|
|
}
|
|
}
|
|
|
|
private void load() throws IOException {
|
|
reset();
|
|
reader = repo.newObjectReader();
|
|
|
|
Ref ref = repo.getRefDatabase().exactRef(REFS_GPG_KEYS);
|
|
if (ref == null) {
|
|
return;
|
|
}
|
|
try (RevWalk rw = new RevWalk(reader)) {
|
|
tip = rw.parseCommit(ref.getObjectId());
|
|
notes = NoteMap.read(reader, tip);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read public keys with the given key ID.
|
|
*
|
|
* <p>Keys should not be trusted unless checked with {@link PublicKeyChecker}.
|
|
*
|
|
* <p>Multiple calls to this method use the same state of the key ref; to reread the ref, call
|
|
* {@link #close()} first.
|
|
*
|
|
* @param keyId key ID.
|
|
* @return any keys found that could be successfully parsed.
|
|
* @throws PGPException if an error occurred parsing the key data.
|
|
* @throws IOException if an error occurred reading the repository data.
|
|
*/
|
|
public PGPPublicKeyRingCollection get(long keyId) throws PGPException, IOException {
|
|
return new PGPPublicKeyRingCollection(get(keyId, null));
|
|
}
|
|
|
|
/**
|
|
* Read public key with the given fingerprint.
|
|
*
|
|
* <p>Keys should not be trusted unless checked with {@link PublicKeyChecker}.
|
|
*
|
|
* <p>Multiple calls to this method use the same state of the key ref; to reread the ref, call
|
|
* {@link #close()} first.
|
|
*
|
|
* @param fingerprint key fingerprint.
|
|
* @return the key if found, or {@code null}.
|
|
* @throws PGPException if an error occurred parsing the key data.
|
|
* @throws IOException if an error occurred reading the repository data.
|
|
*/
|
|
public PGPPublicKeyRing get(byte[] fingerprint) throws PGPException, IOException {
|
|
List<PGPPublicKeyRing> keyRings = get(Fingerprint.getId(fingerprint), fingerprint);
|
|
return !keyRings.isEmpty() ? keyRings.get(0) : null;
|
|
}
|
|
|
|
private List<PGPPublicKeyRing> get(long keyId, byte[] fp) throws IOException {
|
|
if (reader == null) {
|
|
load();
|
|
}
|
|
if (notes == null) {
|
|
return Collections.emptyList();
|
|
}
|
|
Note note = notes.getNote(keyObjectId(keyId));
|
|
if (note == null) {
|
|
return Collections.emptyList();
|
|
}
|
|
|
|
List<PGPPublicKeyRing> keys = new ArrayList<>();
|
|
try (InputStream in = reader.open(note.getData(), OBJ_BLOB).openStream()) {
|
|
while (true) {
|
|
@SuppressWarnings("unchecked")
|
|
Iterator<Object> it = new BcPGPObjectFactory(new ArmoredInputStream(in)).iterator();
|
|
if (!it.hasNext()) {
|
|
break;
|
|
}
|
|
Object obj = it.next();
|
|
if (obj instanceof PGPPublicKeyRing) {
|
|
PGPPublicKeyRing kr = (PGPPublicKeyRing) obj;
|
|
if (fp == null || Arrays.equals(fp, kr.getPublicKey().getFingerprint())) {
|
|
keys.add(kr);
|
|
}
|
|
}
|
|
checkState(!it.hasNext(), "expected one PGP object per ArmoredInputStream");
|
|
}
|
|
return keys;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add a public key to the store.
|
|
*
|
|
* <p>Multiple calls may be made to buffer keys in memory, and they are not saved until {@link
|
|
* #save(CommitBuilder)} is called.
|
|
*
|
|
* @param keyRing a key ring containing exactly one public master key.
|
|
*/
|
|
public void add(PGPPublicKeyRing keyRing) {
|
|
int numMaster = 0;
|
|
for (PGPPublicKey key : keyRing) {
|
|
if (key.isMasterKey()) {
|
|
numMaster++;
|
|
}
|
|
}
|
|
// We could have an additional sanity check to ensure all subkeys belong to
|
|
// this master key, but that requires doing actual signature verification
|
|
// here. The alternative is insane but harmless.
|
|
if (numMaster != 1) {
|
|
throw new IllegalArgumentException("Exactly 1 master key is required, found " + numMaster);
|
|
}
|
|
Fingerprint fp = new Fingerprint(keyRing.getPublicKey().getFingerprint());
|
|
toAdd.put(fp, keyRing);
|
|
toRemove.remove(fp);
|
|
}
|
|
|
|
/**
|
|
* Remove a public key from the store.
|
|
*
|
|
* <p>Multiple calls may be made to buffer deletes in memory, and they are not saved until {@link
|
|
* #save(CommitBuilder)} is called.
|
|
*
|
|
* @param fingerprint the fingerprint of the key to remove.
|
|
*/
|
|
public void remove(byte[] fingerprint) {
|
|
Fingerprint fp = new Fingerprint(fingerprint);
|
|
toAdd.remove(fp);
|
|
toRemove.add(fp);
|
|
}
|
|
|
|
/**
|
|
* Save pending keys to the store.
|
|
*
|
|
* <p>One commit is created and the ref updated. The pending list is cleared if and only if the
|
|
* ref update succeeds, which allows for easy retries in case of lock failure.
|
|
*
|
|
* @param cb commit builder with at least author and identity populated; tree and parent are
|
|
* ignored.
|
|
* @return result of the ref update.
|
|
*/
|
|
public RefUpdate.Result save(CommitBuilder cb) throws PGPException, IOException {
|
|
if (toAdd.isEmpty() && toRemove.isEmpty()) {
|
|
return RefUpdate.Result.NO_CHANGE;
|
|
}
|
|
if (reader == null) {
|
|
load();
|
|
}
|
|
if (notes == null) {
|
|
notes = NoteMap.newEmptyMap();
|
|
}
|
|
ObjectId newTip;
|
|
try (ObjectInserter ins = repo.newObjectInserter()) {
|
|
for (PGPPublicKeyRing keyRing : toAdd.values()) {
|
|
saveToNotes(ins, keyRing);
|
|
}
|
|
for (Fingerprint fp : toRemove) {
|
|
deleteFromNotes(ins, fp);
|
|
}
|
|
cb.setTreeId(notes.writeTree(ins));
|
|
if (cb.getTreeId().equals(tip != null ? tip.getTree() : EMPTY_TREE)) {
|
|
return RefUpdate.Result.NO_CHANGE;
|
|
}
|
|
|
|
if (tip != null) {
|
|
cb.setParentId(tip);
|
|
}
|
|
if (cb.getMessage() == null) {
|
|
int n = toAdd.size() + toRemove.size();
|
|
cb.setMessage(String.format("Update %d public key%s", n, n != 1 ? "s" : ""));
|
|
}
|
|
newTip = ins.insert(cb);
|
|
ins.flush();
|
|
}
|
|
|
|
RefUpdate ru = repo.updateRef(PublicKeyStore.REFS_GPG_KEYS);
|
|
ru.setExpectedOldObjectId(tip);
|
|
ru.setNewObjectId(newTip);
|
|
ru.setRefLogIdent(cb.getCommitter());
|
|
ru.setRefLogMessage("Store public keys", true);
|
|
RefUpdate.Result result = ru.update();
|
|
reset();
|
|
switch (result) {
|
|
case FAST_FORWARD:
|
|
case NEW:
|
|
case NO_CHANGE:
|
|
toAdd.clear();
|
|
toRemove.clear();
|
|
break;
|
|
case FORCED:
|
|
case IO_FAILURE:
|
|
case LOCK_FAILURE:
|
|
case NOT_ATTEMPTED:
|
|
case REJECTED:
|
|
case REJECTED_CURRENT_BRANCH:
|
|
case RENAMED:
|
|
case REJECTED_MISSING_OBJECT:
|
|
case REJECTED_OTHER_REASON:
|
|
default:
|
|
break;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private void saveToNotes(ObjectInserter ins, PGPPublicKeyRing keyRing)
|
|
throws PGPException, IOException {
|
|
long keyId = keyRing.getPublicKey().getKeyID();
|
|
PGPPublicKeyRingCollection existing = get(keyId);
|
|
List<PGPPublicKeyRing> toWrite = new ArrayList<>(existing.size() + 1);
|
|
boolean replaced = false;
|
|
for (PGPPublicKeyRing kr : existing) {
|
|
if (sameKey(keyRing, kr)) {
|
|
toWrite.add(keyRing);
|
|
replaced = true;
|
|
} else {
|
|
toWrite.add(kr);
|
|
}
|
|
}
|
|
if (!replaced) {
|
|
toWrite.add(keyRing);
|
|
}
|
|
notes.set(keyObjectId(keyId), ins.insert(OBJ_BLOB, keysToArmored(toWrite)));
|
|
}
|
|
|
|
private void deleteFromNotes(ObjectInserter ins, Fingerprint fp)
|
|
throws PGPException, IOException {
|
|
long keyId = fp.getId();
|
|
PGPPublicKeyRingCollection existing = get(keyId);
|
|
List<PGPPublicKeyRing> toWrite = new ArrayList<>(existing.size());
|
|
for (PGPPublicKeyRing kr : existing) {
|
|
if (!fp.equalsBytes(kr.getPublicKey().getFingerprint())) {
|
|
toWrite.add(kr);
|
|
}
|
|
}
|
|
if (toWrite.size() == existing.size()) {
|
|
return;
|
|
} else if (!toWrite.isEmpty()) {
|
|
notes.set(keyObjectId(keyId), ins.insert(OBJ_BLOB, keysToArmored(toWrite)));
|
|
} else {
|
|
notes.remove(keyObjectId(keyId));
|
|
}
|
|
}
|
|
|
|
private static boolean sameKey(PGPPublicKeyRing kr1, PGPPublicKeyRing kr2) {
|
|
return Arrays.equals(kr1.getPublicKey().getFingerprint(), kr2.getPublicKey().getFingerprint());
|
|
}
|
|
|
|
private static byte[] keysToArmored(List<PGPPublicKeyRing> keys) throws IOException {
|
|
ByteArrayOutputStream out = new ByteArrayOutputStream(4096 * keys.size());
|
|
for (PGPPublicKeyRing kr : keys) {
|
|
try (ArmoredOutputStream aout = new ArmoredOutputStream(out)) {
|
|
kr.encode(aout);
|
|
}
|
|
}
|
|
return out.toByteArray();
|
|
}
|
|
|
|
public static String keyToString(PGPPublicKey key) {
|
|
Iterator<String> it = key.getUserIDs();
|
|
return String.format(
|
|
"%s %s(%s)",
|
|
keyIdToString(key.getKeyID()),
|
|
it.hasNext() ? it.next() + " " : "",
|
|
Fingerprint.toString(key.getFingerprint()));
|
|
}
|
|
|
|
public static String keyIdToString(long keyId) {
|
|
// Match key ID format from gpg --list-keys.
|
|
return String.format("%08X", (int) keyId);
|
|
}
|
|
|
|
static ObjectId keyObjectId(long keyId) {
|
|
byte[] buf = new byte[Constants.OBJECT_ID_LENGTH];
|
|
NB.encodeInt64(buf, 0, keyId);
|
|
return ObjectId.fromRaw(buf);
|
|
}
|
|
}
|