diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java index da66929e9c..a36d71666d 100644 --- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java +++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java @@ -57,6 +57,12 @@ public class RefNames { public static final String EDIT_PREFIX = "edit-"; + /** + * Special ref for GPG public keys used by {@link + * com.google.gerrit.server.git.SignedPushPreReceiveHook}. + */ + public static final String REFS_GPG_KEYS = REFS + "gpg-keys"; + public static String fullName(String ref) { return ref.startsWith(REFS) ? ref : REFS_HEADS + ref; } diff --git a/gerrit-server/BUCK b/gerrit-server/BUCK index c43c722a8e..378462b7ab 100644 --- a/gerrit-server/BUCK +++ b/gerrit-server/BUCK @@ -213,6 +213,9 @@ java_test( '//lib:grappa', '//lib:gwtorm', '//lib:truth', + '//lib/bouncycastle:bcprov', + '//lib/bouncycastle:bcpg', + '//lib/bouncycastle:bcpkix', '//lib/guice:guice', '//lib/guice:guice-assistedinject', '//lib/jgit:jgit', diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java index 7d2b306998..a6a7c9f52e 100644 --- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java +++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java @@ -79,6 +79,7 @@ import com.google.gerrit.server.git.MergeQueue; import com.google.gerrit.server.git.MergeUtil; import com.google.gerrit.server.git.NotesBranchUtil; import com.google.gerrit.server.git.ReceivePackInitializer; +import com.google.gerrit.server.git.SignedPushModule; import com.google.gerrit.server.git.TagCache; import com.google.gerrit.server.git.TransferConfig; import com.google.gerrit.server.git.validators.CommitValidationListener; @@ -178,6 +179,7 @@ public class GerritGlobalModule extends FactoryModule { install(new NoteDbModule()); install(new PrologModule()); install(new SshAddressesModule()); + install(new SignedPushModule()); install(ThreadLocalRequestContext.module()); bind(AccountResolver.class); diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/SignedPushModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/SignedPushModule.java new file mode 100644 index 0000000000..050756ba60 --- /dev/null +++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/SignedPushModule.java @@ -0,0 +1,60 @@ +// 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.server.git; + +import com.google.common.collect.Lists; +import com.google.gerrit.extensions.registration.DynamicSet; +import com.google.gerrit.reviewdb.client.Project; +import com.google.gerrit.server.util.BouncyCastleUtil; +import com.google.inject.AbstractModule; +import com.google.inject.Inject; +import com.google.inject.Singleton; + +import org.eclipse.jgit.transport.PreReceiveHookChain; +import org.eclipse.jgit.transport.ReceivePack; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class SignedPushModule extends AbstractModule { + private static final Logger log = + LoggerFactory.getLogger(SignedPushModule.class); + + @Override + protected void configure() { + if (BouncyCastleUtil.havePGP()) { + DynamicSet.bind(binder(), ReceivePackInitializer.class) + .to(Initializer.class); + } else { + log.info("BouncyCastle PGP not installed; signed push verification is" + + " disabled"); + } + } + + @Singleton + private static class Initializer implements ReceivePackInitializer { + private final SignedPushPreReceiveHook hook; + + @Inject + Initializer(SignedPushPreReceiveHook hook) { + this.hook = hook; + } + + @Override + public void init(Project.NameKey project, ReceivePack rp) { + rp.setPreReceiveHook(PreReceiveHookChain.newChain(Lists.newArrayList( + hook, rp.getPreReceiveHook()))); + } + } +} diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/SignedPushPreReceiveHook.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/SignedPushPreReceiveHook.java new file mode 100644 index 0000000000..cb25d06053 --- /dev/null +++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/SignedPushPreReceiveHook.java @@ -0,0 +1,244 @@ +// 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.server.git; + +import static org.eclipse.jgit.lib.Constants.OBJ_BLOB; + +import com.google.gerrit.reviewdb.client.RefNames; +import com.google.gerrit.server.config.AllUsersName; +import com.google.inject.Inject; +import com.google.inject.Singleton; + +import org.bouncycastle.bcpg.ArmoredInputStream; +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPObjectFactory; +import org.bouncycastle.openpgp.PGPPublicKey; +import org.bouncycastle.openpgp.PGPPublicKeyRing; +import org.bouncycastle.openpgp.PGPSignature; +import org.bouncycastle.openpgp.PGPSignatureList; +import org.bouncycastle.openpgp.bc.BcPGPObjectFactory; +import org.bouncycastle.openpgp.operator.bc.BcPGPContentVerifierBuilderProvider; +import org.eclipse.jgit.lib.Constants; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.Ref; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.notes.Note; +import org.eclipse.jgit.notes.NoteMap; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.transport.PreReceiveHook; +import org.eclipse.jgit.transport.PushCertificate; +import org.eclipse.jgit.transport.PushCertificate.NonceStatus; +import org.eclipse.jgit.transport.ReceiveCommand; +import org.eclipse.jgit.transport.ReceivePack; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.ByteBuffer; +import java.util.Collection; +import java.util.Iterator; + +/** + * Pre-receive hook to validate signed pushes. + *

+ * If configured, prior to processing any push using {@link ReceiveCommits}, + * requires that any push certificate present must be valid. + */ +@Singleton +public class SignedPushPreReceiveHook implements PreReceiveHook { + private static final Logger log = + LoggerFactory.getLogger(SignedPushPreReceiveHook.class); + + private final GitRepositoryManager repoManager; + private final AllUsersName allUsers; + + @Inject + public SignedPushPreReceiveHook( + GitRepositoryManager repoManager, + AllUsersName allUsers) { + this.repoManager = repoManager; + this.allUsers = allUsers; + } + + @Override + public void onPreReceive(ReceivePack rp, + Collection commands) { + try (Writer msgOut = new OutputStreamWriter(rp.getMessageOutputStream())) { + PushCertificate cert = rp.getPushCertificate(); + if (cert == null) { + return; + } + if (cert.getNonceStatus() != NonceStatus.OK) { + rejectInvalid(commands); + return; + } + verifySignature(cert, commands, msgOut); + } catch (IOException e) { + log.error("Error verifying push certificate", e); + reject(commands, "push cert error"); + } + } + + private void verifySignature(PushCertificate cert, + Collection commands, Writer msgOut) throws IOException { + PGPSignature sig = readSignature(cert); + if (sig == null) { + msgOut.write("Invalid signature format\n"); + rejectInvalid(commands); + return; + } + PGPPublicKey key = readPublicKey(sig.getKeyID()); + if (key == null) { + msgOut.write("No valid public key found for ID " + + keyIdToString(sig.getKeyID()) + "\n"); + rejectInvalid(commands); + return; + } + try { + sig.init(new BcPGPContentVerifierBuilderProvider(), key); + sig.update(Constants.encode(cert.toText())); + if (!sig.verify()) { + msgOut.write("Push certificate signature does not match\n"); + rejectInvalid(commands); + } + return; + } catch (PGPException e) { + msgOut.write( + "Push certificate verification error: " + e.getMessage() + "\n"); + rejectInvalid(commands); + return; + } + } + + private PGPSignature readSignature(PushCertificate cert) throws IOException { + ArmoredInputStream in = new ArmoredInputStream( + new ByteArrayInputStream(Constants.encode(cert.getSignature()))); + PGPObjectFactory factory = new BcPGPObjectFactory(in); + PGPSignature sig = null; + + Object obj; + while ((obj = factory.nextObject()) != null) { + if (!(obj instanceof PGPSignatureList)) { + log.error("Unexpected packet in push cert: {}", + obj.getClass().getSimpleName()); + return null; + } + if (sig != null) { + log.error("Multiple signature packets found in push cert"); + return null; + } + PGPSignatureList sigs = (PGPSignatureList) obj; + if (sigs.size() != 1) { + log.error("Expected 1 signature in push cert, found {}", sigs.size()); + return null; + } + sig = sigs.get(0); + } + return sig; + } + + private PGPPublicKey readPublicKey(long keyId) throws IOException { + try (Repository repo = repoManager.openRepository(allUsers); + RevWalk rw = new RevWalk(repo)) { + Ref ref = repo.getRefDatabase().exactRef(RefNames.REFS_GPG_KEYS); + if (ref == null) { + return null; + } + NoteMap notes = NoteMap.read( + rw.getObjectReader(), rw.parseCommit(ref.getObjectId())); + Note note = notes.getNote(keyObjectId(keyId)); + if (note == null) { + return null; + } + + try (InputStream objIn = + rw.getObjectReader().open(note.getData(), OBJ_BLOB).openStream(); + ArmoredInputStream in = new ArmoredInputStream(objIn)) { + PGPObjectFactory factory = new BcPGPObjectFactory(in); + PGPPublicKey matched = null; + Object obj; + while ((obj = factory.nextObject()) != null) { + if (!(obj instanceof PGPPublicKeyRing)) { + // TODO(dborowitz): Support assertions signed by a trusted key. + log.info("Ignoring {} packet in {}", + obj.getClass().getSimpleName(), note.getName()); + continue; + } + PGPPublicKeyRing keyRing = (PGPPublicKeyRing) obj; + PGPPublicKey key = keyRing.getPublicKey(keyId); + if (key == null) { + log.warn("Public key ring in {} does not contain key ID {}", + note.getName(), keyObjectId(keyId)); + continue; + } + if (matched != null) { + // TODO(dborowitz): Try all keys. + log.warn("Ignoring key with duplicate ID: {}", toString(key)); + continue; + } + matched = key; + } + // TODO(dborowitz): Additional key verification, at least user ID + // signature. + return matched; + } + } + } + + static ObjectId keyObjectId(long keyId) { + // Right-pad key IDs in network byte order to ObjectId length. This allows + // us to reuse the fanout code in NoteMap for free. (If we ever fix the + // fanout code to work with variable-length byte strings, we will need to + // fall back to this key format during a transition period.) + ByteBuffer buf = ByteBuffer.wrap(new byte[Constants.OBJECT_ID_LENGTH]); + buf.putLong(keyId); + return ObjectId.fromRaw(buf.array()); + } + + static String toString(PGPPublicKey key) { + @SuppressWarnings("unchecked") + Iterator it = key.getUserIDs(); + ByteBuffer buf = ByteBuffer.wrap(key.getFingerprint()); + return String.format( + "%s %s(%04X %04X %04X %04X %04X %04X %04X %04X %04X %04X)", + keyIdToString(key.getKeyID()), + it.hasNext() ? it.next() + " " : "", + buf.getShort(), buf.getShort(), buf.getShort(), buf.getShort(), + buf.getShort(), buf.getShort(), buf.getShort(), buf.getShort(), + buf.getShort(), buf.getShort()); + } + + private static void reject(Collection commands, + String reason) { + for (ReceiveCommand cmd : commands) { + if (cmd.getResult() == ReceiveCommand.Result.NOT_ATTEMPTED) { + cmd.setResult(ReceiveCommand.Result.REJECTED_OTHER_REASON, reason); + } + } + } + + static String keyIdToString(long keyId) { + // Match key ID format from gpg --list-keys. + return String.format("%08X", (int) keyId); + } + + private static void rejectInvalid(Collection commands) { + reject(commands, "invalid push cert"); + } +} diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/SignedPushPreReceiveHookTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/git/SignedPushPreReceiveHookTest.java new file mode 100644 index 0000000000..f0cfe180ad --- /dev/null +++ b/gerrit-server/src/test/java/com/google/gerrit/server/git/SignedPushPreReceiveHookTest.java @@ -0,0 +1,90 @@ +// 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.server.git; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.gerrit.server.git.SignedPushPreReceiveHook.keyIdToString; + +import org.bouncycastle.bcpg.ArmoredInputStream; +import org.bouncycastle.openpgp.PGPPublicKey; +import org.bouncycastle.openpgp.PGPPublicKeyRing; +import org.bouncycastle.openpgp.operator.bc.BcKeyFingerprintCalculator; +import org.eclipse.jgit.lib.Constants; +import org.junit.Before; +import org.junit.Test; + +import java.io.ByteArrayInputStream; + +public class SignedPushPreReceiveHookTest { + // ./pubring.gpg + // ------------- + // pub 1024R/30A5A053 2015-06-16 [expires: 2015-06-17] + // Key fingerprint = 96D6 DE78 E6D8 DA49 9387 1F31 FA09 A0C4 30A5 A053 + // uid A U. Thor + // sub 1024R/D6831DC8 2015-06-16 [expires: 2015-06-17] + private static final String PUBKEY = + "-----BEGIN PGP PUBLIC KEY BLOCK-----\n" + + "Version: GnuPG v1\n" + + "\n" + + "mI0EVYCBUQEEALCKzuY6M68RRRm6PS1F322lpHSHTdW9PIURm5B//tbfS32EN6lM\n" + + "ISwJxhanpZanv2o4mbV3V8oLT3jMVDPJ3dqmOZJdJs37l+dxCVJ3ycFe1LHtT2oT\n" + + "eRyC5PxD7UY5PdDe97mjp7yrp/bx1hE6XqGV0nDGrkJXc8A35u3WzIF5ABEBAAG0\n" + + "IEEgVS4gVGhvciA8YV91X3Rob3JAZXhhbXBsZS5jb20+iL4EEwECACgFAlWAgVEC\n" + + "GwMFCQABUYAGCwkIBwMCBhUIAgkKCwQWAgMBAh4BAheAAAoJEPoJoMQwpaBTjhoD\n" + + "/0MRCX1zBjEKIfzFYeSEg/OcSLbAkUD7un5YTfpgds3oUNIKlIgovWO24TQxrCCu\n" + + "5pSzN/WfRSzPFhj9HahY/5yh+EGd6HmIU2v/k5I3LwTPEOcZUi1SzOScSv6JOO9Q\n" + + "3srVilCu3h6TNW1UGBNjfOr1NdmkWfsUZcjsEc/XrfBGuI0EVYCBUQEEAL0UP9jJ\n" + + "eLj3klCCa2tmwdgyFiSf9T+Yoed4I3v3ag2F0/CWrCJr3e1ogSs4Bdts0WptI+Nu\n" + + "QIq40AYszewq55dTcB4lbNAYE4svVYQ5AGz78iKzljaBFhyT6ePdZ5wfb+8Jqu1l\n" + + "7wRwzRI5Jn3OXCmdGm/dmoUNG136EA9A4ZLLABEBAAGIpQQYAQIADwUCVYCBUQIb\n" + + "DAUJAAFRgAAKCRD6CaDEMKWgU5JTA/9XjwPFZ5NseNROMhYZMmje1/ixISb2jaVc\n" + + "9m9RLCl8Y3RCY9NNdU5FinTIX9LsRTrJlW6FSG5sin8mwx9jq0eGE1TBEKND5klT\n" + + "TmsG0jx1dZG9kWDy6lPnIWw2/4W+N0fK/Cw6WEL1Xg7RLi4NQ9Bi2WoxJii9bWMv\n" + + "yy35U6UfPQ==\n" + + "=0GL9\n" + + "-----END PGP PUBLIC KEY BLOCK-----\n"; + + private PGPPublicKey key; + + @Before + public void setUp() throws Exception { + ArmoredInputStream in = new ArmoredInputStream( + new ByteArrayInputStream(Constants.encode(PUBKEY))); + PGPPublicKeyRing keyRing = + new PGPPublicKeyRing(in, new BcKeyFingerprintCalculator()); + key = keyRing.getPublicKey(); + } + + @Test + public void testKeyIdToString() throws Exception { + assertThat(keyIdToString(key.getKeyID())) + .isEqualTo("30A5A053"); + } + + @Test + public void testKeyToString() throws Exception { + assertThat(SignedPushPreReceiveHook.toString(key)) + .isEqualTo("30A5A053 A U. Thor " + + " (96D6 DE78 E6D8 DA49 9387 1F31 FA09 A0C4 30A5 A053)"); + } + + @Test + public void testKeyObjectId() throws Exception { + String objId = SignedPushPreReceiveHook.keyObjectId(key.getKeyID()).name(); + assertThat(objId).isEqualTo("fa09a0c430a5a053000000000000000000000000"); + assertThat(objId.substring(8, 16)) + .isEqualTo(keyIdToString(key.getKeyID()).toLowerCase()); + } +}