| // 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 com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.GPG_KEYS_MODIFICATION; |
| import static java.nio.charset.StandardCharsets.UTF_8; |
| import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID; |
| import static org.eclipse.jgit.lib.Constants.OBJ_BLOB; |
| |
| import com.google.common.base.Preconditions; |
| import com.google.common.io.ByteStreams; |
| import com.google.gerrit.common.Nullable; |
| import com.google.gerrit.git.LockFailureException; |
| import com.google.gerrit.git.ObjectIds; |
| import com.google.gerrit.server.update.context.RefUpdateContext; |
| import java.io.ByteArrayInputStream; |
| 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.errors.IncorrectObjectTypeException; |
| import org.eclipse.jgit.errors.MissingObjectException; |
| import org.eclipse.jgit.lib.CommitBuilder; |
| 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>Subkeys are mapped to the master GPG key in the same NoteMap. |
| * |
| * <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 { |
| /** 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. |
| */ |
| @Nullable |
| public static PGPPublicKey getSigner( |
| Iterable<PGPPublicKeyRing> keyRings, PGPSignature sig, byte[] data) throws PGPException { |
| for (PGPPublicKeyRing kr : keyRings) { |
| // Possibly return a signing subkey in case it differs from the master public key |
| PGPPublicKey k = kr.getPublicKey(sig.getKeyID()); |
| if (k == null) { |
| throw new IllegalStateException( |
| "No public key found for ID: " + keyIdToString(sig.getKeyID())); |
| } |
| sig.init(new BcPGPContentVerifierBuilderProvider(), k); |
| sig.update(data); |
| if (sig.verify()) { |
| // If the signature was made using a subkey, return the main public key. |
| // This enables further validity checks, like user ID checks, that can only |
| // be performed using the master public key. |
| return kr.getPublicKey(); |
| } |
| } |
| 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. |
| */ |
| @Nullable |
| 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. |
| */ |
| @Nullable |
| 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(); |
| } |
| |
| return get(keyObjectId(keyId), fp); |
| } |
| |
| private List<PGPPublicKeyRing> get(ObjectId keyObjectId, byte[] fp) throws IOException { |
| Note note = notes.getNote(keyObjectId); |
| if (note == null) { |
| return Collections.emptyList(); |
| } |
| |
| return readKeysFromNote(note, fp); |
| } |
| |
| private List<PGPPublicKeyRing> readKeysFromNote(Note note, byte[] fp) |
| throws IOException, MissingObjectException, IncorrectObjectTypeException { |
| boolean foundAtLeastOneKey = false; |
| List<PGPPublicKeyRing> keys = new ArrayList<>(); |
| ObjectId data = note.getData(); |
| try (InputStream stream = reader.open(data, OBJ_BLOB).openStream()) { |
| byte[] bytes = ByteStreams.toByteArray(stream); |
| InputStream in = new ByteArrayInputStream(bytes); |
| while (true) { |
| @SuppressWarnings("unchecked") |
| Iterator<Object> it = new BcPGPObjectFactory(new ArmoredInputStream(in)).iterator(); |
| if (!it.hasNext()) { |
| break; |
| } |
| foundAtLeastOneKey = true; |
| 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"); |
| } |
| |
| if (foundAtLeastOneKey) { |
| return keys; |
| } |
| |
| // Subkey handling |
| String id = new String(bytes, UTF_8); |
| Preconditions.checkArgument(ObjectId.isId(id), "Not valid SHA1: " + id); |
| return get(ObjectId.fromString(id), fp); |
| } |
| } |
| |
| public void rebuildSubkeyMasterKeyMap() |
| throws MissingObjectException, IncorrectObjectTypeException, IOException { |
| if (reader == null) { |
| load(); |
| } |
| if (notes != null) { |
| try (ObjectInserter ins = repo.newObjectInserter()) { |
| for (Note note : notes) { |
| for (PGPPublicKeyRing keyRing : |
| new PGPPublicKeyRingCollection(readKeysFromNote(note, null))) { |
| long masterKeyId = keyRing.getPublicKey().getKeyID(); |
| ObjectId masterKeyObjectId = keyObjectId(masterKeyId); |
| saveSubkeyMapping(ins, keyRing, masterKeyId, masterKeyObjectId); |
| } |
| } |
| } |
| } |
| } |
| |
| /** |
| * 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_ID)) { |
| 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(); |
| } |
| try (RefUpdateContext ctx = RefUpdateContext.open(GPG_KEYS_MODIFICATION)) { |
| 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 LOCK_FAILURE: |
| throw new LockFailureException("Failed to store public keys", ru); |
| case FORCED: |
| case IO_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 masterKeyId = keyRing.getPublicKey().getKeyID(); |
| PGPPublicKeyRingCollection existing = get(masterKeyId); |
| 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); |
| } |
| |
| ObjectId masterKeyObjectId = keyObjectId(masterKeyId); |
| notes.set(masterKeyObjectId, ins.insert(OBJ_BLOB, keysToArmored(toWrite))); |
| |
| saveSubkeyMapping(ins, keyRing, masterKeyId, masterKeyObjectId); |
| } |
| |
| private void saveSubkeyMapping( |
| ObjectInserter ins, PGPPublicKeyRing keyRing, long masterKeyId, ObjectId masterKeyObjectId) |
| throws IOException { |
| // Subkey handling |
| byte[] masterKeyBytes = masterKeyObjectId.name().getBytes(UTF_8); |
| ObjectId masterKeyObject = null; |
| for (PGPPublicKey key : keyRing) { |
| long subKeyId = key.getKeyID(); |
| // Skip master public key |
| if (masterKeyId == subKeyId) { |
| continue; |
| } |
| |
| // Insert master key object only once for all subkeys |
| if (masterKeyObject == null) { |
| masterKeyObject = ins.insert(OBJ_BLOB, masterKeyBytes); |
| } |
| |
| ObjectId subkeyObjectId = keyObjectId(subKeyId); |
| Preconditions.checkArgument( |
| notes.get(subkeyObjectId) == null || notes.get(subkeyObjectId).equals(masterKeyObject), |
| "Master key differs for subkey: " + subkeyObjectId.name()); |
| notes.set(subkeyObjectId, masterKeyObject); |
| } |
| } |
| |
| 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; |
| } |
| |
| ObjectId keyObjectId = keyObjectId(keyId); |
| if (!toWrite.isEmpty()) { |
| notes.set(keyObjectId, ins.insert(OBJ_BLOB, keysToArmored(toWrite))); |
| } else { |
| PGPPublicKeyRing keyRing = get(fp.get()); |
| |
| for (PGPPublicKey key : keyRing) { |
| long subKeyId = key.getKeyID(); |
| // Skip master public key |
| if (keyId == subKeyId) { |
| continue; |
| } |
| notes.remove(keyObjectId(subKeyId)); |
| } |
| |
| notes.remove(keyObjectId); |
| } |
| } |
| |
| 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[ObjectIds.LEN]; |
| NB.encodeInt64(buf, 0, keyId); |
| return ObjectId.fromRaw(buf); |
| } |
| } |