Move most GPG-related code to a gerrit-gpg module
Bouncy Castle is still an optional dependency for Gerrit, so we want to avoid accidentally attempting to load Bouncy Castle classes when they might not be available. Rather than try to guard every org.bouncycastle.* reference with a hasPGP() check, reduce the surface area of calls that actually require Bouncy Castle. Move almost all code that calls Bouncy Castle into a new module, gerrit-gpg. Callers need only interact with this module by installing the GpgModule, which is careful to protect all Bouncy Castle class loading with the appropriate havePGP() check. Moreover, this module doesn't need to be installed in the gerrit-server package at all, so we can break the compile-time dependency between gerrit-server and Bouncy Castle, so accidentally introducing a dependency on Bouncy Castle results in a compile error. The REST API and extension APIs dealing with GPG keys only refer to the GpgKeyInfo POJO, and don't need to actually refer to Bouncy Castle classes. Add a shim interface, GpgApiAdapter, that is used by AccountApiImpl to process GPG keys. GpgModule binds this interface to either the Bouncy Castle enabled implementation, or a not-implemented implementation. Since there are various places in the server code where we want to inspect whether signed push is enabled at the server level, but we don't want to have to call into gerrit-gpg code to do this, bind a boolean with @EnableSignedPush from GpgModule. Change-Id: Idbab00a52d86216cae73d02876d56be54aef6581
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
// 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 org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
import org.bouncycastle.openpgp.PGPPublicKey;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.security.Security;
|
||||
|
||||
/** Utility methods for Bouncy Castle. */
|
||||
public class BouncyCastleUtil {
|
||||
/**
|
||||
* Check for Bouncy Castle PGP support.
|
||||
* <p>
|
||||
* As a side effect, adds {@link BouncyCastleProvider} as a security provider.
|
||||
*
|
||||
* @return whether Bouncy Castle PGP support is enabled.
|
||||
*/
|
||||
public static boolean havePGP() {
|
||||
try {
|
||||
Class.forName(PGPPublicKey.class.getName());
|
||||
addBouncyCastleProvider();
|
||||
return true;
|
||||
} catch (NoClassDefFoundError | ClassNotFoundException | SecurityException
|
||||
| NoSuchMethodException | InstantiationException
|
||||
| IllegalAccessException | InvocationTargetException
|
||||
| ClassCastException noBouncyCastle) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void addBouncyCastleProvider() throws ClassNotFoundException,
|
||||
SecurityException, NoSuchMethodException, InstantiationException,
|
||||
IllegalAccessException, InvocationTargetException {
|
||||
Class<?> clazz = Class.forName(BouncyCastleProvider.class.getName());
|
||||
Constructor<?> constructor = clazz.getConstructor();
|
||||
Security.addProvider((java.security.Provider) constructor.newInstance());
|
||||
}
|
||||
|
||||
private BouncyCastleUtil() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// 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 java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/** Result of checking an object like a key or signature. */
|
||||
public class CheckResult {
|
||||
private final List<String> problems;
|
||||
|
||||
CheckResult(String... problems) {
|
||||
this(Arrays.asList(problems));
|
||||
}
|
||||
|
||||
CheckResult(List<String> problems) {
|
||||
this.problems = Collections.unmodifiableList(new ArrayList<>(problems));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return whether the result is entirely ok, i.e. has passed any verification
|
||||
* or validation checks.
|
||||
*/
|
||||
public boolean isOk() {
|
||||
return problems.isEmpty();
|
||||
}
|
||||
|
||||
/** @return any problems encountered during checking. */
|
||||
public List<String> getProblems() {
|
||||
return problems;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
StringBuilder sb = new StringBuilder(getClass().getSimpleName())
|
||||
.append('[');
|
||||
for (int i = 0; i < problems.size(); i++) {
|
||||
if (i > 0) {
|
||||
sb.append(", ");
|
||||
}
|
||||
sb.append(problems.get(i));
|
||||
}
|
||||
return sb.append(']').toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// 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.checkArgument;
|
||||
|
||||
import org.eclipse.jgit.util.NB;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
public class Fingerprint {
|
||||
private final byte[] fp;
|
||||
|
||||
public static String toString(byte[] fp) {
|
||||
checkLength(fp);
|
||||
return String.format(
|
||||
"%04X %04X %04X %04X %04X %04X %04X %04X %04X %04X",
|
||||
NB.decodeUInt16(fp, 0), NB.decodeUInt16(fp, 2), NB.decodeUInt16(fp, 4),
|
||||
NB.decodeUInt16(fp, 6), NB.decodeUInt16(fp, 8), NB.decodeUInt16(fp, 10),
|
||||
NB.decodeUInt16(fp, 12), NB.decodeUInt16(fp, 14),
|
||||
NB.decodeUInt16(fp, 16), NB.decodeUInt16(fp, 18));
|
||||
}
|
||||
|
||||
private static byte[] checkLength(byte[] fp) {
|
||||
checkArgument(fp.length == 20,
|
||||
"fingerprint must be 20 bytes, got %s", fp.length);
|
||||
return fp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a fingerprint byte array.
|
||||
* <p>
|
||||
* The newly created Fingerprint object takes ownership of the byte array,
|
||||
* which must not be subsequently modified. (Most callers, such as hex
|
||||
* decoders and {@code
|
||||
* org.bouncycastle.openpgp.PGPPublicKey#getFingerprint()}, already produce
|
||||
* fresh byte arrays).
|
||||
*
|
||||
* @param fp 20-byte fingerprint byte array to wrap.
|
||||
*/
|
||||
public Fingerprint(byte[] fp) {
|
||||
this.fp = checkLength(fp);
|
||||
}
|
||||
|
||||
public byte[] get() {
|
||||
return fp;
|
||||
}
|
||||
|
||||
public boolean equalsBytes(byte[] bytes) {
|
||||
return Arrays.equals(fp, bytes);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
// Same hash code as ObjectId: second int word.
|
||||
return NB.decodeInt32(fp, 4);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
return (o instanceof Fingerprint) && equalsBytes(((Fingerprint) o).fp);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return toString(fp);
|
||||
}
|
||||
|
||||
public long getId() {
|
||||
return NB.decodeInt64(fp, 12);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// 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.gerrit.gpg.PublicKeyStore.keyIdToString;
|
||||
import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_GPGKEY;
|
||||
|
||||
import com.google.common.collect.FluentIterable;
|
||||
import com.google.common.collect.Ordering;
|
||||
import com.google.gerrit.common.PageLinks;
|
||||
import com.google.gerrit.reviewdb.client.AccountExternalId;
|
||||
import com.google.gerrit.server.IdentifiedUser;
|
||||
import com.google.gerrit.server.config.CanonicalWebUrl;
|
||||
import com.google.inject.Inject;
|
||||
import com.google.inject.Provider;
|
||||
import com.google.inject.Singleton;
|
||||
|
||||
import org.bouncycastle.openpgp.PGPException;
|
||||
import org.bouncycastle.openpgp.PGPPublicKey;
|
||||
import org.bouncycastle.openpgp.PGPSignature;
|
||||
import org.bouncycastle.openpgp.operator.bc.BcPGPContentVerifierBuilderProvider;
|
||||
import org.eclipse.jgit.transport.PushCertificateIdent;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Checker for GPG public keys including Gerrit-specific checks.
|
||||
* <p>
|
||||
* For Gerrit, keys must contain a self-signed user ID certification matching a
|
||||
* trusted external ID in the database, or an email address thereof.
|
||||
*/
|
||||
@Singleton
|
||||
public class GerritPublicKeyChecker extends PublicKeyChecker {
|
||||
private static final Logger log =
|
||||
LoggerFactory.getLogger(GerritPublicKeyChecker.class);
|
||||
|
||||
private final String webUrl;
|
||||
private final Provider<IdentifiedUser> userProvider;
|
||||
|
||||
@Inject
|
||||
GerritPublicKeyChecker(
|
||||
@CanonicalWebUrl String webUrl,
|
||||
Provider<IdentifiedUser> userProvider) {
|
||||
this.webUrl = webUrl;
|
||||
this.userProvider = userProvider;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void checkCustom(PGPPublicKey key, long expectedKeyId,
|
||||
List<String> problems) {
|
||||
try {
|
||||
Set<String> allowedUserIds = getAllowedUserIds();
|
||||
if (allowedUserIds.isEmpty()) {
|
||||
problems.add("No identities found for user; check "
|
||||
+ webUrl + "#" + PageLinks.SETTINGS_WEBIDENT);
|
||||
return;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Iterator<String> userIds = key.getUserIDs();
|
||||
while (userIds.hasNext()) {
|
||||
String userId = userIds.next();
|
||||
if (isAllowed(userId, allowedUserIds)) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Iterator<PGPSignature> sigs = key.getSignaturesForID(userId);
|
||||
while (sigs.hasNext()) {
|
||||
if (isValidCertification(key, sigs.next(), userId)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
problems.add(missingUserIds(allowedUserIds));
|
||||
} catch (PGPException e) {
|
||||
String msg = "Error checking user IDs for key";
|
||||
log.warn(msg + " " + keyIdToString(key.getKeyID()), e);
|
||||
problems.add(msg);
|
||||
}
|
||||
}
|
||||
|
||||
private Set<String> getAllowedUserIds() {
|
||||
IdentifiedUser user = userProvider.get();
|
||||
Set<String> result = new HashSet<>();
|
||||
result.addAll(user.getEmailAddresses());
|
||||
for (AccountExternalId extId : user.state().getExternalIds()) {
|
||||
if (extId.isScheme(SCHEME_GPGKEY)) {
|
||||
continue; // Omit GPG keys.
|
||||
}
|
||||
result.add(extId.getExternalId());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static boolean isAllowed(String userId, Set<String> allowedUserIds) {
|
||||
return allowedUserIds.contains(userId)
|
||||
|| allowedUserIds.contains(
|
||||
PushCertificateIdent.parse(userId).getEmailAddress());
|
||||
}
|
||||
|
||||
private static boolean isValidCertification(PGPPublicKey key,
|
||||
PGPSignature sig, String userId) throws PGPException {
|
||||
if (sig.getSignatureType() != PGPSignature.DEFAULT_CERTIFICATION
|
||||
&& sig.getSignatureType() != PGPSignature.POSITIVE_CERTIFICATION) {
|
||||
return false;
|
||||
}
|
||||
if (sig.getKeyID() != key.getKeyID()) {
|
||||
return false;
|
||||
}
|
||||
// TODO(dborowitz): Handle certification revocations:
|
||||
// - Is there a revocation by either this key or another key trusted by the
|
||||
// server?
|
||||
// - Does such a revocation postdate all other valid certifications?
|
||||
|
||||
sig.init(new BcPGPContentVerifierBuilderProvider(), key);
|
||||
return sig.verifyCertification(userId, key);
|
||||
}
|
||||
|
||||
private static String missingUserIds(Set<String> allowedUserIds) {
|
||||
StringBuilder sb = new StringBuilder("Key must contain a valid"
|
||||
+ " certification for one of the following identities:\n");
|
||||
Iterator<String> sorted = FluentIterable.from(allowedUserIds)
|
||||
.toSortedList(Ordering.natural())
|
||||
.iterator();
|
||||
while (sorted.hasNext()) {
|
||||
sb.append(" ").append(sorted.next());
|
||||
if (sorted.hasNext()) {
|
||||
sb.append('\n');
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// 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.gerrit.gpg.server.GpgKey.GPG_KEY_KIND;
|
||||
import static com.google.gerrit.server.account.AccountResource.ACCOUNT_KIND;
|
||||
|
||||
import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
|
||||
import com.google.gerrit.extensions.common.GpgKeyInfo;
|
||||
import com.google.gerrit.extensions.registration.DynamicMap;
|
||||
import com.google.gerrit.extensions.restapi.IdString;
|
||||
import com.google.gerrit.extensions.restapi.NotImplementedException;
|
||||
import com.google.gerrit.extensions.restapi.RestApiModule;
|
||||
import com.google.gerrit.gpg.api.GpgApiAdapterImpl;
|
||||
import com.google.gerrit.gpg.api.GpgKeyApiImpl;
|
||||
import com.google.gerrit.gpg.server.DeleteGpgKey;
|
||||
import com.google.gerrit.gpg.server.GpgKeys;
|
||||
import com.google.gerrit.gpg.server.PostGpgKeys;
|
||||
import com.google.gerrit.server.EnableSignedPush;
|
||||
import com.google.gerrit.server.account.AccountResource;
|
||||
import com.google.gerrit.server.api.accounts.GpgApiAdapter;
|
||||
|
||||
import org.eclipse.jgit.lib.Config;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class GpgModule extends RestApiModule {
|
||||
private static final Logger log = LoggerFactory.getLogger(GpgModule.class);
|
||||
|
||||
private final Config cfg;
|
||||
|
||||
public GpgModule(Config cfg) {
|
||||
this.cfg = cfg;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void configure() {
|
||||
boolean configEnableSignedPush =
|
||||
cfg.getBoolean("receive", null, "enableSignedPush", false);
|
||||
boolean havePgp = BouncyCastleUtil.havePGP();
|
||||
boolean enableSignedPush = configEnableSignedPush && havePgp;
|
||||
bindConstant().annotatedWith(EnableSignedPush.class).to(enableSignedPush);
|
||||
|
||||
if (configEnableSignedPush && !havePgp) {
|
||||
log.info("Bouncy Castle PGP not installed; signed push verification is"
|
||||
+ " disabled");
|
||||
}
|
||||
if (!enableSignedPush) {
|
||||
bind(GpgApiAdapter.class).to(NoGpgApi.class);
|
||||
return;
|
||||
}
|
||||
|
||||
install(new SignedPushModule());
|
||||
bind(GpgApiAdapter.class).to(GpgApiAdapterImpl.class);
|
||||
factory(GpgKeyApiImpl.Factory.class);
|
||||
|
||||
DynamicMap.mapOf(binder(), GPG_KEY_KIND);
|
||||
|
||||
child(ACCOUNT_KIND, "gpgkeys").to(GpgKeys.class);
|
||||
post(ACCOUNT_KIND, "gpgkeys").to(PostGpgKeys.class);
|
||||
get(GPG_KEY_KIND).to(GpgKeys.Get.class);
|
||||
delete(GPG_KEY_KIND).to(DeleteGpgKey.class);
|
||||
}
|
||||
|
||||
private static class NoGpgApi implements GpgApiAdapter {
|
||||
private static final String MSG = "GPG key APIs disabled";
|
||||
|
||||
@Override
|
||||
public Map<String, GpgKeyInfo> listGpgKeys(AccountResource account) {
|
||||
throw new NotImplementedException(MSG);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, GpgKeyInfo> putGpgKeys(AccountResource account,
|
||||
List<String> add, List<String> delete) {
|
||||
throw new NotImplementedException(MSG);
|
||||
}
|
||||
|
||||
@Override
|
||||
public GpgKeyApi gpgKey(AccountResource account, IdString idStr) {
|
||||
throw new NotImplementedException(MSG);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// 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.gerrit.gpg.PublicKeyStore.keyIdToString;
|
||||
|
||||
import org.bouncycastle.openpgp.PGPPublicKey;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Checker for GPG public keys for use in a push certificate. */
|
||||
public class PublicKeyChecker {
|
||||
/**
|
||||
* Check a public key.
|
||||
*
|
||||
* @param key the public key.
|
||||
*/
|
||||
public final CheckResult check(PGPPublicKey key) {
|
||||
return check(key, key.getKeyID());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a public key.
|
||||
*
|
||||
* @param key the public key.
|
||||
* @param expectedKeyId the key ID that the caller expects.
|
||||
*/
|
||||
public final CheckResult check(PGPPublicKey key, long expectedKeyId) {
|
||||
List<String> problems = new ArrayList<>();
|
||||
if (key.getKeyID() != expectedKeyId) {
|
||||
problems.add(
|
||||
"Public key does not match ID " + keyIdToString(expectedKeyId));
|
||||
}
|
||||
if (key.isRevoked()) {
|
||||
// TODO(dborowitz): isRevoked is overeager:
|
||||
// http://www.bouncycastle.org/jira/browse/BJB-45
|
||||
problems.add("Key is revoked");
|
||||
}
|
||||
|
||||
long validSecs = key.getValidSeconds();
|
||||
if (validSecs != 0) {
|
||||
long createdSecs = key.getCreationTime().getTime() / 1000;
|
||||
long nowSecs = System.currentTimeMillis() / 1000;
|
||||
if (nowSecs - createdSecs > validSecs) {
|
||||
problems.add("Key is expired");
|
||||
}
|
||||
}
|
||||
checkCustom(key, expectedKeyId, problems);
|
||||
return new CheckResult(problems);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform custom checks.
|
||||
* <p>
|
||||
* Default implementation does nothing, but may be overridden by subclasses.
|
||||
*
|
||||
* @param key the public key.
|
||||
* @param expectedKeyId the key ID that the caller expects.
|
||||
* @param problems list to which any problems should be added.
|
||||
*/
|
||||
public void checkCustom(PGPPublicKey key, long expectedKeyId,
|
||||
List<String> problems) {
|
||||
// Default implementation does nothing.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
// 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 com.google.gerrit.reviewdb.client.RefNames;
|
||||
|
||||
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.bc.BcPGPObjectFactory;
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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");
|
||||
|
||||
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(RefNames.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 {
|
||||
if (reader == null) {
|
||||
load();
|
||||
}
|
||||
if (notes == null) {
|
||||
return empty();
|
||||
}
|
||||
Note note = notes.getNote(keyObjectId(keyId));
|
||||
if (note == null) {
|
||||
return empty();
|
||||
}
|
||||
|
||||
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) {
|
||||
keys.add((PGPPublicKeyRing) obj);
|
||||
}
|
||||
checkState(!it.hasNext(),
|
||||
"expected one PGP object per ArmoredInputStream");
|
||||
}
|
||||
return new PGPPublicKeyRingCollection(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(RefNames.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;
|
||||
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.size() > 0) {
|
||||
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();
|
||||
}
|
||||
|
||||
private static PGPPublicKeyRingCollection empty()
|
||||
throws PGPException, IOException {
|
||||
return new PGPPublicKeyRingCollection(
|
||||
Collections.<PGPPublicKeyRing> emptyList());
|
||||
}
|
||||
|
||||
public static String keyToString(PGPPublicKey key) {
|
||||
@SuppressWarnings("unchecked")
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// 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.gerrit.gpg.PublicKeyStore.keyIdToString;
|
||||
import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
|
||||
|
||||
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.PGPPublicKeyRingCollection;
|
||||
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.Repository;
|
||||
import org.eclipse.jgit.transport.PushCertificate;
|
||||
import org.eclipse.jgit.transport.PushCertificate.NonceStatus;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Checker for push certificates. */
|
||||
public abstract class PushCertificateChecker {
|
||||
private final PublicKeyChecker publicKeyChecker;
|
||||
|
||||
protected PushCertificateChecker(PublicKeyChecker publicKeyChecker) {
|
||||
this.publicKeyChecker = publicKeyChecker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a push certificate.
|
||||
*
|
||||
* @return result of the check.
|
||||
* @throws PGPException if an error occurred during GPG checks.
|
||||
* @throws IOException if an error occurred reading from the repository.
|
||||
*/
|
||||
public final CheckResult check(PushCertificate cert) throws PGPException, IOException {
|
||||
if (cert.getNonceStatus() != NonceStatus.OK) {
|
||||
return new CheckResult("Invalid nonce");
|
||||
}
|
||||
PGPSignature sig = readSignature(cert);
|
||||
if (sig == null) {
|
||||
return new CheckResult("Invalid signature format");
|
||||
}
|
||||
Repository repo = getRepository();
|
||||
List<String> problems = new ArrayList<>();
|
||||
try (PublicKeyStore store = new PublicKeyStore(repo)) {
|
||||
checkSignature(sig, cert, store.get(sig.getKeyID()), problems);
|
||||
checkCustom(repo, problems);
|
||||
return new CheckResult(problems);
|
||||
} finally {
|
||||
if (shouldClose(repo)) {
|
||||
repo.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the repository that this checker should operate on.
|
||||
* <p>
|
||||
* This method is called once per call to {@link #check(PushCertificate)}.
|
||||
*
|
||||
* @return the repository.
|
||||
* @throws IOException if an error occurred reading the repository.
|
||||
*/
|
||||
protected abstract Repository getRepository() throws IOException;
|
||||
|
||||
/**
|
||||
* @param repo a repository previously returned by {@link #getRepository()}.
|
||||
* @return whether this repository should be closed before returning from
|
||||
* {@link #check(PushCertificate)}.
|
||||
*/
|
||||
protected abstract boolean shouldClose(Repository repo);
|
||||
|
||||
/**
|
||||
* Perform custom checks.
|
||||
* <p>
|
||||
* Default implementation does nothing, but may be overridden by subclasses.
|
||||
*
|
||||
* @param repo a repository previously returned by {@link #getRepository()}.
|
||||
* @param problems list to which any problems should be added.
|
||||
*/
|
||||
protected void checkCustom(Repository repo, List<String> problems) {
|
||||
// Default implementation does nothing.
|
||||
}
|
||||
|
||||
private PGPSignature readSignature(PushCertificate cert) throws IOException {
|
||||
ArmoredInputStream in = new ArmoredInputStream(
|
||||
new ByteArrayInputStream(Constants.encode(cert.getSignature())));
|
||||
PGPObjectFactory factory = new BcPGPObjectFactory(in);
|
||||
Object obj;
|
||||
while ((obj = factory.nextObject()) != null) {
|
||||
if (obj instanceof PGPSignatureList) {
|
||||
PGPSignatureList sigs = (PGPSignatureList) obj;
|
||||
if (!sigs.isEmpty()) {
|
||||
return sigs.get(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void checkSignature(PGPSignature sig,
|
||||
PushCertificate cert, PGPPublicKeyRingCollection keys,
|
||||
List<String> problems) {
|
||||
List<String> deferredProblems = new ArrayList<>();
|
||||
boolean anyKeys = false;
|
||||
for (PGPPublicKeyRing kr : keys) {
|
||||
PGPPublicKey k = kr.getPublicKey();
|
||||
anyKeys = true;
|
||||
try {
|
||||
sig.init(new BcPGPContentVerifierBuilderProvider(), k);
|
||||
sig.update(Constants.encode(cert.toText()));
|
||||
if (!sig.verify()) {
|
||||
// TODO(dborowitz): Privacy issues with exposing fingerprint/user ID
|
||||
// of keys having the same ID as the pusher's key?
|
||||
deferredProblems.add(
|
||||
"Signature not valid with public key: " + keyToString(k));
|
||||
continue;
|
||||
}
|
||||
CheckResult result = publicKeyChecker.check(k, sig.getKeyID());
|
||||
if (result.isOk()) {
|
||||
return;
|
||||
}
|
||||
StringBuilder err = new StringBuilder("Invalid public key (")
|
||||
.append(keyToString(k))
|
||||
.append("):");
|
||||
for (int i = 0; i < result.getProblems().size(); i++) {
|
||||
err.append('\n').append(" ").append(result.getProblems().get(i));
|
||||
}
|
||||
problems.add(err.toString());
|
||||
return;
|
||||
} catch (PGPException e) {
|
||||
deferredProblems.add(
|
||||
"Error checking signature with public key (" + keyToString(k)
|
||||
+ ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
if (!anyKeys) {
|
||||
problems.add(
|
||||
"No public keys found for Key ID " + keyIdToString(sig.getKeyID()));
|
||||
} else {
|
||||
problems.addAll(deferredProblems);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// 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 com.google.common.base.Strings;
|
||||
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.EnableSignedPush;
|
||||
import com.google.gerrit.server.config.AllUsersName;
|
||||
import com.google.gerrit.server.config.GerritServerConfig;
|
||||
import com.google.gerrit.server.git.GitRepositoryManager;
|
||||
import com.google.gerrit.server.git.ReceivePackInitializer;
|
||||
import com.google.gerrit.server.project.ProjectCache;
|
||||
import com.google.gerrit.server.project.ProjectState;
|
||||
import com.google.inject.AbstractModule;
|
||||
import com.google.inject.Inject;
|
||||
import com.google.inject.Provider;
|
||||
import com.google.inject.ProvisionException;
|
||||
import com.google.inject.Singleton;
|
||||
|
||||
import org.eclipse.jgit.lib.Config;
|
||||
import org.eclipse.jgit.lib.Repository;
|
||||
import org.eclipse.jgit.transport.PreReceiveHookChain;
|
||||
import org.eclipse.jgit.transport.ReceivePack;
|
||||
import org.eclipse.jgit.transport.SignedPushConfig;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Random;
|
||||
|
||||
class SignedPushModule extends AbstractModule {
|
||||
private static final Logger log =
|
||||
LoggerFactory.getLogger(SignedPushModule.class);
|
||||
|
||||
@Override
|
||||
protected void configure() {
|
||||
if (!BouncyCastleUtil.havePGP()) {
|
||||
throw new ProvisionException("Bouncy Castle PGP not installed");
|
||||
}
|
||||
bind(PublicKeyChecker.class).to(GerritPublicKeyChecker.class);
|
||||
bind(PublicKeyStore.class).toProvider(StoreProvider.class);
|
||||
DynamicSet.bind(binder(), ReceivePackInitializer.class)
|
||||
.to(Initializer.class);
|
||||
}
|
||||
|
||||
@Singleton
|
||||
private static class Initializer implements ReceivePackInitializer {
|
||||
private final SignedPushConfig signedPushConfig;
|
||||
private final SignedPushPreReceiveHook hook;
|
||||
private final ProjectCache projectCache;
|
||||
|
||||
@Inject
|
||||
Initializer(@GerritServerConfig Config cfg,
|
||||
@EnableSignedPush boolean enableSignedPush,
|
||||
SignedPushPreReceiveHook hook,
|
||||
ProjectCache projectCache) {
|
||||
this.hook = hook;
|
||||
this.projectCache = projectCache;
|
||||
|
||||
if (enableSignedPush) {
|
||||
String seed = cfg.getString("receive", null, "certNonceSeed");
|
||||
if (Strings.isNullOrEmpty(seed)) {
|
||||
seed = randomString(64);
|
||||
}
|
||||
signedPushConfig = new SignedPushConfig();
|
||||
signedPushConfig.setCertNonceSeed(seed);
|
||||
signedPushConfig.setCertNonceSlopLimit(
|
||||
cfg.getInt("receive", null, "certNonceSlop", 5 * 60));
|
||||
} else {
|
||||
signedPushConfig = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void init(Project.NameKey project, ReceivePack rp) {
|
||||
ProjectState ps = projectCache.get(project);
|
||||
if (!ps.isEnableSignedPush()) {
|
||||
rp.setSignedPushConfig(null);
|
||||
return;
|
||||
}
|
||||
if (signedPushConfig == null) {
|
||||
log.error("receive.enableSignedPush is true for project {} but"
|
||||
+ " false in gerrit.config, so signed push verification is"
|
||||
+ " disabled", project.get());
|
||||
}
|
||||
rp.setSignedPushConfig(signedPushConfig);
|
||||
rp.setPreReceiveHook(PreReceiveHookChain.newChain(Lists.newArrayList(
|
||||
hook, rp.getPreReceiveHook())));
|
||||
}
|
||||
}
|
||||
|
||||
@Singleton
|
||||
private static class StoreProvider implements Provider<PublicKeyStore> {
|
||||
private final GitRepositoryManager repoManager;
|
||||
private final AllUsersName allUsers;
|
||||
|
||||
@Inject
|
||||
StoreProvider(GitRepositoryManager repoManager,
|
||||
AllUsersName allUsers) {
|
||||
this.repoManager = repoManager;
|
||||
this.allUsers = allUsers;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PublicKeyStore get() {
|
||||
final Repository repo;
|
||||
try {
|
||||
repo = repoManager.openRepository(allUsers);
|
||||
} catch (IOException e) {
|
||||
throw new ProvisionException("Cannot open " + allUsers, e);
|
||||
}
|
||||
return new PublicKeyStore(repo) {
|
||||
@Override
|
||||
public void close() {
|
||||
try {
|
||||
super.close();
|
||||
} finally {
|
||||
repo.close();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static String randomString(int len) {
|
||||
Random random;
|
||||
try {
|
||||
random = SecureRandom.getInstance("SHA1PRNG");
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
StringBuilder sb = new StringBuilder(len);
|
||||
for (int i = 0; i < len; i++) {
|
||||
sb.append((char) random.nextInt());
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// 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 com.google.gerrit.server.config.AllUsersName;
|
||||
import com.google.gerrit.server.git.GitRepositoryManager;
|
||||
import com.google.inject.Inject;
|
||||
import com.google.inject.Singleton;
|
||||
|
||||
import org.bouncycastle.openpgp.PGPException;
|
||||
import org.eclipse.jgit.lib.Repository;
|
||||
import org.eclipse.jgit.transport.PreReceiveHook;
|
||||
import org.eclipse.jgit.transport.PushCertificate;
|
||||
import org.eclipse.jgit.transport.ReceiveCommand;
|
||||
import org.eclipse.jgit.transport.ReceivePack;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collection;
|
||||
|
||||
/**
|
||||
* Pre-receive hook to check signed pushes.
|
||||
* <p>
|
||||
* If configured, prior to processing any push using
|
||||
* {@link com.google.gerrit.server.git.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;
|
||||
private final PublicKeyChecker keyChecker;
|
||||
|
||||
@Inject
|
||||
public SignedPushPreReceiveHook(
|
||||
GitRepositoryManager repoManager,
|
||||
AllUsersName allUsers,
|
||||
PublicKeyChecker keyChecker) {
|
||||
this.repoManager = repoManager;
|
||||
this.allUsers = allUsers;
|
||||
this.keyChecker = keyChecker;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPreReceive(ReceivePack rp,
|
||||
Collection<ReceiveCommand> commands) {
|
||||
try {
|
||||
PushCertificate cert = rp.getPushCertificate();
|
||||
if (cert == null) {
|
||||
return;
|
||||
}
|
||||
PushCertificateChecker checker = new PushCertificateChecker(keyChecker) {
|
||||
@Override
|
||||
protected Repository getRepository() throws IOException {
|
||||
return repoManager.openRepository(allUsers);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean shouldClose(Repository repo) {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
CheckResult result = checker.check(cert);
|
||||
if (!result.isOk()) {
|
||||
for (String problem : result.getProblems()) {
|
||||
rp.sendMessage(problem);
|
||||
}
|
||||
reject(commands, "invalid push cert");
|
||||
}
|
||||
} catch (PGPException | IOException e) {
|
||||
log.error("Error checking push certificate", e);
|
||||
reject(commands, "push cert error");
|
||||
}
|
||||
}
|
||||
|
||||
private static void reject(Collection<ReceiveCommand> commands,
|
||||
String reason) {
|
||||
for (ReceiveCommand cmd : commands) {
|
||||
if (cmd.getResult() == ReceiveCommand.Result.NOT_ATTEMPTED) {
|
||||
cmd.setResult(ReceiveCommand.Result.REJECTED_OTHER_REASON, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// 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.api;
|
||||
|
||||
import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
|
||||
import com.google.gerrit.extensions.common.GpgKeyInfo;
|
||||
import com.google.gerrit.extensions.restapi.IdString;
|
||||
import com.google.gerrit.extensions.restapi.RestApiException;
|
||||
import com.google.gerrit.gpg.server.GpgKeys;
|
||||
import com.google.gerrit.gpg.server.PostGpgKeys;
|
||||
import com.google.gerrit.server.GpgException;
|
||||
import com.google.gerrit.server.account.AccountResource;
|
||||
import com.google.gerrit.server.api.accounts.GpgApiAdapter;
|
||||
import com.google.gwtorm.server.OrmException;
|
||||
import com.google.inject.Inject;
|
||||
|
||||
import org.bouncycastle.openpgp.PGPException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class GpgApiAdapterImpl implements GpgApiAdapter {
|
||||
private final PostGpgKeys postGpgKeys;
|
||||
private final GpgKeys gpgKeys;
|
||||
private final GpgKeyApiImpl.Factory gpgKeyApiFactory;
|
||||
|
||||
@Inject
|
||||
GpgApiAdapterImpl(
|
||||
PostGpgKeys postGpgKeys,
|
||||
GpgKeys gpgKeys,
|
||||
GpgKeyApiImpl.Factory gpgKeyApiFactory) {
|
||||
this.postGpgKeys = postGpgKeys;
|
||||
this.gpgKeys = gpgKeys;
|
||||
this.gpgKeyApiFactory = gpgKeyApiFactory;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, GpgKeyInfo> listGpgKeys(AccountResource account)
|
||||
throws RestApiException, GpgException {
|
||||
try {
|
||||
return gpgKeys.list().apply(account);
|
||||
} catch (OrmException | PGPException | IOException e) {
|
||||
throw new GpgException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, GpgKeyInfo> putGpgKeys(AccountResource account,
|
||||
List<String> add, List<String> delete)
|
||||
throws RestApiException, GpgException {
|
||||
PostGpgKeys.Input in = new PostGpgKeys.Input();
|
||||
in.add = add;
|
||||
in.delete = delete;
|
||||
try {
|
||||
return postGpgKeys.apply(account, in);
|
||||
} catch (PGPException | OrmException | IOException e) {
|
||||
throw new GpgException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public GpgKeyApi gpgKey(AccountResource account, IdString idStr)
|
||||
throws RestApiException, GpgException {
|
||||
try {
|
||||
return gpgKeyApiFactory.create(gpgKeys.parse(account, idStr));
|
||||
} catch (PGPException | OrmException | IOException e) {
|
||||
throw new GpgException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// 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.api;
|
||||
|
||||
import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
|
||||
import com.google.gerrit.extensions.common.GpgKeyInfo;
|
||||
import com.google.gerrit.extensions.restapi.RestApiException;
|
||||
import com.google.gerrit.gpg.server.DeleteGpgKey;
|
||||
import com.google.gerrit.gpg.server.GpgKey;
|
||||
import com.google.gerrit.gpg.server.GpgKeys;
|
||||
import com.google.gwtorm.server.OrmException;
|
||||
import com.google.inject.assistedinject.Assisted;
|
||||
import com.google.inject.assistedinject.AssistedInject;
|
||||
|
||||
import org.bouncycastle.openpgp.PGPException;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class GpgKeyApiImpl implements GpgKeyApi {
|
||||
public interface Factory {
|
||||
GpgKeyApiImpl create(GpgKey rsrc);
|
||||
}
|
||||
|
||||
private final GpgKeys.Get get;
|
||||
private final DeleteGpgKey delete;
|
||||
private final GpgKey rsrc;
|
||||
|
||||
@AssistedInject
|
||||
GpgKeyApiImpl(
|
||||
GpgKeys.Get get,
|
||||
DeleteGpgKey delete,
|
||||
@Assisted GpgKey rsrc) {
|
||||
this.get = get;
|
||||
this.delete = delete;
|
||||
this.rsrc = rsrc;
|
||||
}
|
||||
|
||||
@Override
|
||||
public GpgKeyInfo get() throws RestApiException {
|
||||
try {
|
||||
return get.apply(rsrc);
|
||||
} catch (IOException e) {
|
||||
throw new RestApiException("Cannot get GPG key", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete() throws RestApiException {
|
||||
try {
|
||||
delete.apply(rsrc, new DeleteGpgKey.Input());
|
||||
} catch (PGPException | OrmException | IOException e) {
|
||||
throw new RestApiException("Cannot delete GPG key", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.gpg.server;
|
||||
|
||||
import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
|
||||
|
||||
import com.google.common.io.BaseEncoding;
|
||||
import com.google.gerrit.extensions.restapi.ResourceConflictException;
|
||||
import com.google.gerrit.extensions.restapi.Response;
|
||||
import com.google.gerrit.extensions.restapi.RestModifyView;
|
||||
import com.google.gerrit.gpg.PublicKeyStore;
|
||||
import com.google.gerrit.gpg.server.DeleteGpgKey.Input;
|
||||
import com.google.gerrit.reviewdb.client.AccountExternalId;
|
||||
import com.google.gerrit.reviewdb.server.ReviewDb;
|
||||
import com.google.gerrit.server.GerritPersonIdent;
|
||||
import com.google.gwtorm.server.OrmException;
|
||||
import com.google.inject.Inject;
|
||||
import com.google.inject.Provider;
|
||||
|
||||
import org.bouncycastle.openpgp.PGPException;
|
||||
import org.bouncycastle.openpgp.PGPPublicKey;
|
||||
import org.eclipse.jgit.lib.CommitBuilder;
|
||||
import org.eclipse.jgit.lib.PersonIdent;
|
||||
import org.eclipse.jgit.lib.RefUpdate;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
|
||||
public class DeleteGpgKey implements RestModifyView<GpgKey, Input> {
|
||||
public static class Input {
|
||||
}
|
||||
|
||||
private final Provider<PersonIdent> serverIdent;
|
||||
private final Provider<ReviewDb> db;
|
||||
private final Provider<PublicKeyStore> storeProvider;
|
||||
|
||||
@Inject
|
||||
DeleteGpgKey(@GerritPersonIdent Provider<PersonIdent> serverIdent,
|
||||
Provider<ReviewDb> db,
|
||||
Provider<PublicKeyStore> storeProvider) {
|
||||
this.serverIdent = serverIdent;
|
||||
this.db = db;
|
||||
this.storeProvider = storeProvider;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Response<?> apply(GpgKey rsrc, Input input)
|
||||
throws ResourceConflictException, PGPException, OrmException,
|
||||
IOException {
|
||||
PGPPublicKey key = rsrc.getKeyRing().getPublicKey();
|
||||
AccountExternalId.Key extIdKey = new AccountExternalId.Key(
|
||||
AccountExternalId.SCHEME_GPGKEY,
|
||||
BaseEncoding.base16().encode(key.getFingerprint()));
|
||||
db.get().accountExternalIds().deleteKeys(Collections.singleton(extIdKey));
|
||||
|
||||
try (PublicKeyStore store = storeProvider.get()) {
|
||||
store.remove(rsrc.getKeyRing().getPublicKey().getFingerprint());
|
||||
|
||||
CommitBuilder cb = new CommitBuilder();
|
||||
PersonIdent committer = serverIdent.get();
|
||||
cb.setAuthor(rsrc.getUser().newCommitterIdent(
|
||||
committer.getWhen(), committer.getTimeZone()));
|
||||
cb.setCommitter(committer);
|
||||
cb.setMessage("Delete public key " + keyIdToString(key.getKeyID()));
|
||||
|
||||
RefUpdate.Result saveResult = store.save(cb);
|
||||
switch (saveResult) {
|
||||
case NO_CHANGE:
|
||||
case FAST_FORWARD:
|
||||
break;
|
||||
default:
|
||||
throw new ResourceConflictException(
|
||||
"Failed to delete public key: " + saveResult);
|
||||
}
|
||||
}
|
||||
return Response.none();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// 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.server;
|
||||
|
||||
import com.google.gerrit.extensions.restapi.RestView;
|
||||
import com.google.gerrit.server.IdentifiedUser;
|
||||
import com.google.gerrit.server.account.AccountResource;
|
||||
import com.google.inject.TypeLiteral;
|
||||
|
||||
import org.bouncycastle.openpgp.PGPPublicKeyRing;
|
||||
|
||||
public class GpgKey extends AccountResource {
|
||||
public static final TypeLiteral<RestView<GpgKey>> GPG_KEY_KIND =
|
||||
new TypeLiteral<RestView<GpgKey>>() {};
|
||||
|
||||
private final PGPPublicKeyRing keyRing;
|
||||
|
||||
public GpgKey(IdentifiedUser user, PGPPublicKeyRing keyRing) {
|
||||
super(user);
|
||||
this.keyRing = keyRing;
|
||||
}
|
||||
|
||||
public PGPPublicKeyRing getKeyRing() {
|
||||
return keyRing;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// 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.server;
|
||||
|
||||
import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_GPGKEY;
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.google.common.base.CharMatcher;
|
||||
import com.google.common.base.Predicate;
|
||||
import com.google.common.collect.FluentIterable;
|
||||
import com.google.common.collect.ImmutableList;
|
||||
import com.google.common.io.BaseEncoding;
|
||||
import com.google.gerrit.extensions.common.GpgKeyInfo;
|
||||
import com.google.gerrit.extensions.registration.DynamicMap;
|
||||
import com.google.gerrit.extensions.restapi.AuthException;
|
||||
import com.google.gerrit.extensions.restapi.ChildCollection;
|
||||
import com.google.gerrit.extensions.restapi.IdString;
|
||||
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
|
||||
import com.google.gerrit.extensions.restapi.RestReadView;
|
||||
import com.google.gerrit.extensions.restapi.RestView;
|
||||
import com.google.gerrit.gpg.BouncyCastleUtil;
|
||||
import com.google.gerrit.gpg.Fingerprint;
|
||||
import com.google.gerrit.gpg.PublicKeyStore;
|
||||
import com.google.gerrit.reviewdb.client.Account;
|
||||
import com.google.gerrit.reviewdb.client.AccountExternalId;
|
||||
import com.google.gerrit.reviewdb.server.ReviewDb;
|
||||
import com.google.gerrit.server.account.AccountResource;
|
||||
import com.google.gwtorm.server.OrmException;
|
||||
import com.google.inject.Inject;
|
||||
import com.google.inject.Provider;
|
||||
import com.google.inject.Singleton;
|
||||
|
||||
import org.bouncycastle.bcpg.ArmoredOutputStream;
|
||||
import org.bouncycastle.openpgp.PGPException;
|
||||
import org.bouncycastle.openpgp.PGPPublicKey;
|
||||
import org.bouncycastle.openpgp.PGPPublicKeyRing;
|
||||
import org.eclipse.jgit.util.NB;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
|
||||
@Singleton
|
||||
public class GpgKeys implements
|
||||
ChildCollection<AccountResource, GpgKey> {
|
||||
private static final Logger log = LoggerFactory.getLogger(GpgKeys.class);
|
||||
|
||||
public static String MIME_TYPE = "application/pgp-keys";
|
||||
|
||||
private final DynamicMap<RestView<GpgKey>> views;
|
||||
private final Provider<ReviewDb> db;
|
||||
private final Provider<PublicKeyStore> storeProvider;
|
||||
|
||||
@Inject
|
||||
GpgKeys(DynamicMap<RestView<GpgKey>> views,
|
||||
Provider<ReviewDb> db,
|
||||
Provider<PublicKeyStore> storeProvider) {
|
||||
this.views = views;
|
||||
this.db = db;
|
||||
this.storeProvider = storeProvider;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ListGpgKeys list()
|
||||
throws ResourceNotFoundException, AuthException {
|
||||
checkEnabled();
|
||||
return new ListGpgKeys();
|
||||
}
|
||||
|
||||
@Override
|
||||
public GpgKey parse(AccountResource parent, IdString id)
|
||||
throws ResourceNotFoundException, PGPException, OrmException,
|
||||
IOException {
|
||||
checkEnabled();
|
||||
String str = CharMatcher.WHITESPACE.removeFrom(id.get()).toUpperCase();
|
||||
if ((str.length() != 8 && str.length() != 40)
|
||||
|| !CharMatcher.anyOf("0123456789ABCDEF").matchesAllOf(str)) {
|
||||
throw new ResourceNotFoundException(id);
|
||||
}
|
||||
|
||||
byte[] fp = parseFingerprint(id.get(), getGpgExtIds(parent));
|
||||
try (PublicKeyStore store = storeProvider.get()) {
|
||||
long keyId = keyId(fp);
|
||||
for (PGPPublicKeyRing keyRing : store.get(keyId)) {
|
||||
PGPPublicKey key = keyRing.getPublicKey();
|
||||
if (Arrays.equals(key.getFingerprint(), fp)) {
|
||||
return new GpgKey(parent.getUser(), keyRing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new ResourceNotFoundException(id);
|
||||
}
|
||||
|
||||
static byte[] parseFingerprint(String str,
|
||||
Iterable<AccountExternalId> existingExtIds)
|
||||
throws ResourceNotFoundException {
|
||||
str = CharMatcher.WHITESPACE.removeFrom(str).toUpperCase();
|
||||
if ((str.length() != 8 && str.length() != 40)
|
||||
|| !CharMatcher.anyOf("0123456789ABCDEF").matchesAllOf(str)) {
|
||||
throw new ResourceNotFoundException(str);
|
||||
}
|
||||
byte[] fp = null;
|
||||
for (AccountExternalId extId : existingExtIds) {
|
||||
String fpStr = extId.getSchemeRest();
|
||||
if (!fpStr.endsWith(str)) {
|
||||
continue;
|
||||
} else if (fp != null) {
|
||||
throw new ResourceNotFoundException("Multiple keys found for " + str);
|
||||
}
|
||||
fp = BaseEncoding.base16().decode(fpStr);
|
||||
if (str.length() == 40) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (fp == null) {
|
||||
throw new ResourceNotFoundException(str);
|
||||
}
|
||||
return fp;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DynamicMap<RestView<GpgKey>> views() {
|
||||
return views;
|
||||
}
|
||||
|
||||
public class ListGpgKeys implements RestReadView<AccountResource> {
|
||||
@Override
|
||||
public Map<String, GpgKeyInfo> apply(AccountResource rsrc)
|
||||
throws OrmException, PGPException, IOException {
|
||||
Map<String, GpgKeyInfo> keys = new HashMap<>();
|
||||
try (PublicKeyStore store = storeProvider.get()) {
|
||||
for (AccountExternalId extId : getGpgExtIds(rsrc)) {
|
||||
String fpStr = extId.getSchemeRest();
|
||||
byte[] fp = BaseEncoding.base16().decode(fpStr);
|
||||
boolean found = false;
|
||||
for (PGPPublicKeyRing keyRing : store.get(keyId(fp))) {
|
||||
if (Arrays.equals(keyRing.getPublicKey().getFingerprint(), fp)) {
|
||||
found = true;
|
||||
GpgKeyInfo info = toJson(keyRing);
|
||||
keys.put(info.id, info);
|
||||
info.id = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
log.warn("No public key stored for fingerprint {}",
|
||||
Fingerprint.toString(fp));
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
}
|
||||
|
||||
@Singleton
|
||||
public static class Get implements RestReadView<GpgKey> {
|
||||
@Override
|
||||
public GpgKeyInfo apply(GpgKey rsrc) throws IOException {
|
||||
return toJson(rsrc.getKeyRing());
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public static FluentIterable<AccountExternalId> getGpgExtIds(ReviewDb db,
|
||||
Account.Id accountId) throws OrmException {
|
||||
return FluentIterable
|
||||
.from(db.accountExternalIds().byAccount(accountId))
|
||||
.filter(new Predicate<AccountExternalId>() {
|
||||
@Override
|
||||
public boolean apply(AccountExternalId in) {
|
||||
return in.isScheme(SCHEME_GPGKEY);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private Iterable<AccountExternalId> getGpgExtIds(AccountResource rsrc)
|
||||
throws OrmException {
|
||||
return getGpgExtIds(db.get(), rsrc.getUser().getAccountId());
|
||||
}
|
||||
|
||||
private static long keyId(byte[] fp) {
|
||||
return NB.decodeInt64(fp, fp.length - 8);
|
||||
}
|
||||
|
||||
static void checkEnabled() throws ResourceNotFoundException {
|
||||
if (!BouncyCastleUtil.havePGP()) {
|
||||
throw new ResourceNotFoundException("GPG not enabled");
|
||||
}
|
||||
}
|
||||
|
||||
static GpgKeyInfo toJson(PGPPublicKeyRing keyRing) throws IOException {
|
||||
PGPPublicKey key = keyRing.getPublicKey();
|
||||
GpgKeyInfo info = new GpgKeyInfo();
|
||||
info.id = PublicKeyStore.keyIdToString(key.getKeyID());
|
||||
info.fingerprint = Fingerprint.toString(key.getFingerprint());
|
||||
@SuppressWarnings("unchecked")
|
||||
Iterator<String> userIds = key.getUserIDs();
|
||||
info.userIds = ImmutableList.copyOf(userIds);
|
||||
try (ByteArrayOutputStream out = new ByteArrayOutputStream(4096);
|
||||
ArmoredOutputStream aout = new ArmoredOutputStream(out)) {
|
||||
// This is not exactly the key stored in the store, but is equivalent. In
|
||||
// particular, it will have a Bouncy Castle version string. The armored
|
||||
// stream reader in PublicKeyStore doesn't give us an easy way to extract
|
||||
// the original ASCII armor.
|
||||
key.encode(aout);
|
||||
info.key = new String(out.toByteArray(), UTF_8);
|
||||
}
|
||||
return info;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
// 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.server;
|
||||
|
||||
import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
|
||||
import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
|
||||
import com.google.common.base.Function;
|
||||
import com.google.common.base.Joiner;
|
||||
import com.google.common.collect.ImmutableList;
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
import com.google.common.collect.Iterables;
|
||||
import com.google.common.collect.Lists;
|
||||
import com.google.common.collect.Maps;
|
||||
import com.google.common.collect.Sets;
|
||||
import com.google.common.io.BaseEncoding;
|
||||
import com.google.gerrit.extensions.common.GpgKeyInfo;
|
||||
import com.google.gerrit.extensions.restapi.BadRequestException;
|
||||
import com.google.gerrit.extensions.restapi.ResourceConflictException;
|
||||
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
|
||||
import com.google.gerrit.extensions.restapi.RestModifyView;
|
||||
import com.google.gerrit.gpg.CheckResult;
|
||||
import com.google.gerrit.gpg.Fingerprint;
|
||||
import com.google.gerrit.gpg.PublicKeyChecker;
|
||||
import com.google.gerrit.gpg.PublicKeyStore;
|
||||
import com.google.gerrit.gpg.server.PostGpgKeys.Input;
|
||||
import com.google.gerrit.reviewdb.client.AccountExternalId;
|
||||
import com.google.gerrit.reviewdb.server.ReviewDb;
|
||||
import com.google.gerrit.server.GerritPersonIdent;
|
||||
import com.google.gerrit.server.account.AccountResource;
|
||||
import com.google.gwtorm.server.OrmException;
|
||||
import com.google.inject.Inject;
|
||||
import com.google.inject.Provider;
|
||||
import com.google.inject.Singleton;
|
||||
|
||||
import org.bouncycastle.bcpg.ArmoredInputStream;
|
||||
import org.bouncycastle.openpgp.PGPException;
|
||||
import org.bouncycastle.openpgp.PGPPublicKey;
|
||||
import org.bouncycastle.openpgp.PGPPublicKeyRing;
|
||||
import org.bouncycastle.openpgp.bc.BcPGPObjectFactory;
|
||||
import org.eclipse.jgit.lib.CommitBuilder;
|
||||
import org.eclipse.jgit.lib.PersonIdent;
|
||||
import org.eclipse.jgit.lib.RefUpdate;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Singleton
|
||||
public class PostGpgKeys implements RestModifyView<AccountResource, Input> {
|
||||
public static class Input {
|
||||
public List<String> add;
|
||||
public List<String> delete;
|
||||
}
|
||||
|
||||
private final Provider<PersonIdent> serverIdent;
|
||||
private final Provider<ReviewDb> db;
|
||||
private final Provider<PublicKeyStore> storeProvider;
|
||||
private final PublicKeyChecker checker;
|
||||
|
||||
@Inject
|
||||
PostGpgKeys(@GerritPersonIdent Provider<PersonIdent> serverIdent,
|
||||
Provider<ReviewDb> db,
|
||||
Provider<PublicKeyStore> storeProvider,
|
||||
PublicKeyChecker checker) {
|
||||
this.serverIdent = serverIdent;
|
||||
this.db = db;
|
||||
this.storeProvider = storeProvider;
|
||||
this.checker = checker;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, GpgKeyInfo> apply(AccountResource rsrc, Input input)
|
||||
throws ResourceNotFoundException, BadRequestException,
|
||||
ResourceConflictException, PGPException, OrmException, IOException {
|
||||
GpgKeys.checkEnabled();
|
||||
|
||||
List<AccountExternalId> existingExtIds =
|
||||
GpgKeys.getGpgExtIds(db.get(), rsrc.getUser().getAccountId()).toList();
|
||||
|
||||
try (PublicKeyStore store = storeProvider.get()) {
|
||||
Set<Fingerprint> toRemove = readKeysToRemove(input, existingExtIds);
|
||||
List<PGPPublicKeyRing> newKeys = readKeysToAdd(input, toRemove);
|
||||
List<AccountExternalId> newExtIds = new ArrayList<>(existingExtIds.size());
|
||||
|
||||
for (PGPPublicKeyRing keyRing : newKeys) {
|
||||
PGPPublicKey key = keyRing.getPublicKey();
|
||||
AccountExternalId.Key extIdKey = toExtIdKey(key.getFingerprint());
|
||||
AccountExternalId existing = db.get().accountExternalIds().get(extIdKey);
|
||||
if (existing != null) {
|
||||
if (!existing.getAccountId().equals(rsrc.getUser().getAccountId())) {
|
||||
throw new ResourceConflictException(
|
||||
"GPG key already associated with another account");
|
||||
}
|
||||
} else {
|
||||
newExtIds.add(
|
||||
new AccountExternalId(rsrc.getUser().getAccountId(), extIdKey));
|
||||
}
|
||||
}
|
||||
|
||||
storeKeys(rsrc, newKeys, toRemove);
|
||||
if (!newExtIds.isEmpty()) {
|
||||
db.get().accountExternalIds().insert(newExtIds);
|
||||
}
|
||||
db.get().accountExternalIds().deleteKeys(Iterables.transform(toRemove,
|
||||
new Function<Fingerprint, AccountExternalId.Key>() {
|
||||
@Override
|
||||
public AccountExternalId.Key apply(Fingerprint fp) {
|
||||
return toExtIdKey(fp.get());
|
||||
}
|
||||
}));
|
||||
return toJson(newKeys, toRemove);
|
||||
}
|
||||
}
|
||||
|
||||
private Set<Fingerprint> readKeysToRemove(Input input,
|
||||
List<AccountExternalId> existingExtIds) {
|
||||
if (input.delete == null || input.delete.isEmpty()) {
|
||||
return ImmutableSet.of();
|
||||
}
|
||||
Set<Fingerprint> fingerprints =
|
||||
Sets.newHashSetWithExpectedSize(input.delete.size());
|
||||
for (String id : input.delete) {
|
||||
try {
|
||||
fingerprints.add(new Fingerprint(
|
||||
GpgKeys.parseFingerprint(id, existingExtIds)));
|
||||
} catch (ResourceNotFoundException e) {
|
||||
// Skip removal.
|
||||
}
|
||||
}
|
||||
return fingerprints;
|
||||
}
|
||||
|
||||
private List<PGPPublicKeyRing> readKeysToAdd(Input input,
|
||||
Set<Fingerprint> toRemove)
|
||||
throws BadRequestException, IOException {
|
||||
if (input.add == null || input.add.isEmpty()) {
|
||||
return ImmutableList.of();
|
||||
}
|
||||
List<PGPPublicKeyRing> keyRings = new ArrayList<>(input.add.size());
|
||||
for (String armored : input.add) {
|
||||
try (InputStream in = new ByteArrayInputStream(armored.getBytes(UTF_8));
|
||||
ArmoredInputStream ain = new ArmoredInputStream(in)) {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Object> objs = Lists.newArrayList(new BcPGPObjectFactory(ain));
|
||||
if (objs.size() != 1 || !(objs.get(0) instanceof PGPPublicKeyRing)) {
|
||||
throw new BadRequestException("Expected exactly one PUBLIC KEY BLOCK");
|
||||
}
|
||||
PGPPublicKeyRing keyRing = (PGPPublicKeyRing) objs.get(0);
|
||||
if (toRemove.contains(
|
||||
new Fingerprint(keyRing.getPublicKey().getFingerprint()))) {
|
||||
throw new BadRequestException("Cannot both add and delete key: "
|
||||
+ keyToString(keyRing.getPublicKey()));
|
||||
}
|
||||
keyRings.add(keyRing);
|
||||
}
|
||||
}
|
||||
return keyRings;
|
||||
}
|
||||
|
||||
private void storeKeys(AccountResource rsrc, List<PGPPublicKeyRing> keyRings,
|
||||
Set<Fingerprint> toRemove) throws BadRequestException,
|
||||
ResourceConflictException, PGPException, IOException {
|
||||
try (PublicKeyStore store = storeProvider.get()) {
|
||||
for (PGPPublicKeyRing keyRing : keyRings) {
|
||||
PGPPublicKey key = keyRing.getPublicKey();
|
||||
CheckResult result = checker.check(key);
|
||||
if (!result.isOk()) {
|
||||
throw new BadRequestException(String.format(
|
||||
"Problems with public key %s:\n%s",
|
||||
keyToString(key), Joiner.on('\n').join(result.getProblems())));
|
||||
}
|
||||
store.add(keyRing);
|
||||
}
|
||||
for (Fingerprint fp : toRemove) {
|
||||
store.remove(fp.get());
|
||||
}
|
||||
CommitBuilder cb = new CommitBuilder();
|
||||
PersonIdent committer = serverIdent.get();
|
||||
cb.setAuthor(rsrc.getUser().newCommitterIdent(
|
||||
committer.getWhen(), committer.getTimeZone()));
|
||||
cb.setCommitter(committer);
|
||||
|
||||
RefUpdate.Result saveResult = store.save(cb);
|
||||
switch (saveResult) {
|
||||
case NEW:
|
||||
case FAST_FORWARD:
|
||||
case FORCED:
|
||||
case NO_CHANGE:
|
||||
break;
|
||||
default:
|
||||
// TODO(dborowitz): Backoff and retry on LOCK_FAILURE.
|
||||
throw new ResourceConflictException(
|
||||
"Failed to save public keys: " + saveResult);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final AccountExternalId.Key toExtIdKey(byte[] fp) {
|
||||
return new AccountExternalId.Key(
|
||||
AccountExternalId.SCHEME_GPGKEY,
|
||||
BaseEncoding.base16().encode(fp));
|
||||
}
|
||||
|
||||
private static Map<String, GpgKeyInfo> toJson(
|
||||
Collection<PGPPublicKeyRing> keys,
|
||||
Set<Fingerprint> deleted) throws IOException {
|
||||
Map<String, GpgKeyInfo> infos =
|
||||
Maps.newHashMapWithExpectedSize(keys.size() + deleted.size());
|
||||
for (PGPPublicKeyRing keyRing : keys) {
|
||||
GpgKeyInfo info = GpgKeys.toJson(keyRing);
|
||||
infos.put(info.id, info);
|
||||
info.id = null;
|
||||
}
|
||||
for (Fingerprint fp : deleted) {
|
||||
infos.put(keyIdToString(fp.getId()), new GpgKeyInfo());
|
||||
}
|
||||
return infos;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user