Generate default password during the plugin's init step
During the plugin's init step one can either rely on that the default
password (16 bytes long) under '$site/data/@PLUGIN@/default.key' file
is being generated, provide own configuration or add a new one under
new 'key-id' (that will be marked as default).
Bug: Issue 16192
Change-Id: I99e4024ecf19d828deb2d84b40fe1dd7e1abe23d
diff --git a/github-oauth/src/main/java/com/googlesource/gerrit/plugins/github/oauth/GitHubOAuthConfig.java b/github-oauth/src/main/java/com/googlesource/gerrit/plugins/github/oauth/GitHubOAuthConfig.java
index a826886..7ef81d1 100644
--- a/github-oauth/src/main/java/com/googlesource/gerrit/plugins/github/oauth/GitHubOAuthConfig.java
+++ b/github-oauth/src/main/java/com/googlesource/gerrit/plugins/github/oauth/GitHubOAuthConfig.java
@@ -13,6 +13,8 @@
// limitations under the License.
package com.googlesource.gerrit.plugins.github.oauth;
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.PASSWORD_DEVICE_CONFIG_LABEL;
+
import com.google.common.base.CharMatcher;
import com.google.common.base.MoreObjects;
import com.google.common.base.Preconditions;
@@ -38,13 +40,9 @@
import javax.servlet.http.HttpServletRequest;
import lombok.Getter;
import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
@Singleton
public class GitHubOAuthConfig {
- private static Logger logger = LoggerFactory.getLogger(GitHubOAuthConfig.class);
-
private final Config config;
private final CanonicalWebUrl canonicalWebUrl;
@@ -138,25 +136,16 @@
config.getSubsections(CONF_KEY_SECTION).stream()
.map(KeyConfig::new)
.collect(Collectors.toMap(KeyConfig::getKeyId, Function.identity()));
-
- if (configuredKeyConfig.isEmpty()) {
- logger.warn(
- "No configured '{}' sections found. Default configuration should NOT be used in production code.",
- CONF_KEY_SECTION);
- currentKeyConfig = new KeyConfig();
- keyConfigMap = Map.of(currentKeyConfig.getKeyId(), currentKeyConfig);
- } else {
- keyConfigMap = configuredKeyConfig;
- List<KeyConfig> currentKeyConfigs =
- keyConfigMap.values().stream().filter(KeyConfig::isCurrent).collect(Collectors.toList());
- if (currentKeyConfigs.size() != 1) {
- throw new IllegalStateException(
- String.format(
- "Expected exactly 1 subsection of '%s' to be configured as 'current', %d found",
- CONF_KEY_SECTION, currentKeyConfigs.size()));
- }
- currentKeyConfig = currentKeyConfigs.get(0);
+ keyConfigMap = configuredKeyConfig;
+ List<KeyConfig> currentKeyConfigs =
+ keyConfigMap.values().stream().filter(KeyConfig::isCurrent).collect(Collectors.toList());
+ if (currentKeyConfigs.size() != 1) {
+ throw new IllegalStateException(
+ String.format(
+ "Expected exactly 1 subsection of '%s' to be configured as 'current', %d found",
+ CONF_KEY_SECTION, currentKeyConfigs.size()));
}
+ currentKeyConfig = currentKeyConfigs.get(0);
}
public String getOAuthFinalRedirectUrl(HttpServletRequest req) {
@@ -220,7 +209,6 @@
public class KeyConfig {
- public static final String PASSWORD_DEVICE_DEFAULT = "/dev/zero";
public static final int PASSWORD_LENGTH_DEFAULT = 16;
public static final String CIPHER_ALGORITHM_DEFAULT = "AES/ECB/PKCS5Padding";
public static final String SECRET_KEY_ALGORITHM_DEFAULT = "AES";
@@ -251,11 +239,7 @@
CONF_KEY_SECTION, keyId, KEY_DELIMITER));
}
- this.passwordDevice =
- trimTrailingSlash(
- MoreObjects.firstNonNull(
- config.getString(CONF_KEY_SECTION, keyId, PASSWORD_DEVICE_CONFIG_LABEL),
- PASSWORD_DEVICE_DEFAULT));
+ this.passwordDevice = trimTrailingSlash(getPasswordDeviceOrThrow(config, keyId));
this.passwordLength =
config.getInt(
CONF_KEY_SECTION, keyId, PASSWORD_LENGTH_CONFIG_LABEL, PASSWORD_LENGTH_DEFAULT);
@@ -274,15 +258,6 @@
this.keyId = keyId;
}
- private KeyConfig() {
- passwordDevice = PASSWORD_DEVICE_DEFAULT;
- passwordLength = PASSWORD_LENGTH_DEFAULT;
- isCurrent = true;
- cipherAlgorithm = CIPHER_ALGORITHM_DEFAULT;
- keyId = KEY_ID_DEFAULT;
- secretKeyAlgorithm = SECRET_KEY_ALGORITHM_DEFAULT;
- }
-
public byte[] readPassword() throws IOException {
Path devicePath = Paths.get(passwordDevice);
try (FileInputStream in = new FileInputStream(devicePath.toFile())) {
@@ -311,4 +286,22 @@
return keyId;
}
}
+
+ /**
+ * Method returns the password device value for a given {@code keyId}.
+ *
+ * @throws {@link IllegalStateException} when password device is not configured for {@code keyId}
+ */
+ private static String getPasswordDeviceOrThrow(Config config, String keyId) {
+ String passwordDevice =
+ config.getString(CONF_KEY_SECTION, keyId, KeyConfig.PASSWORD_DEVICE_CONFIG_LABEL);
+ if (Strings.isNullOrEmpty(passwordDevice)) {
+ throw new IllegalStateException(
+ String.format(
+ "Configuration error. Missing %s.%s for key-id '%s'",
+ CONF_KEY_SECTION, PASSWORD_DEVICE_CONFIG_LABEL, keyId));
+ }
+
+ return passwordDevice;
+ }
}
diff --git a/github-oauth/src/main/java/com/googlesource/gerrit/plugins/github/oauth/PasswordGenerator.java b/github-oauth/src/main/java/com/googlesource/gerrit/plugins/github/oauth/PasswordGenerator.java
new file mode 100644
index 0000000..2f36425
--- /dev/null
+++ b/github-oauth/src/main/java/com/googlesource/gerrit/plugins/github/oauth/PasswordGenerator.java
@@ -0,0 +1,99 @@
+// Copyright (C) 2022 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.googlesource.gerrit.plugins.github.oauth;
+
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.PASSWORD_LENGTH_DEFAULT;
+import static java.util.Objects.requireNonNull;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.SecureRandom;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class PasswordGenerator {
+ private static final Logger logger = LoggerFactory.getLogger(PasswordGenerator.class);
+ public static final String DEFAULT_PASSWORD_FILE = "default.key";
+
+ /**
+ * Generates default password and stores under given {@code Path}. Note that if password already
+ * exists it is not regenerated.
+ *
+ * @param passwordFilePath path that should contain the default password; cannot be {@code null}
+ * @throws {@link IllegalStateException} when file denoted by given {@code Path} is a directory,
+ * cannot be read, has invalid length or doesn't exist and cannot be created
+ * @return {@code true} if password was generated, {@code false} if it already exists
+ */
+ public boolean generate(Path passwordFilePath) {
+ requireNonNull(passwordFilePath);
+
+ File passwordFile = passwordFilePath.toFile();
+
+ if (passwordFile.isDirectory()) {
+ throw logErrorAndCreateRuntimeException(
+ "'%s' is directory whilst a regular file was expected.", passwordFilePath);
+ }
+
+ if (passwordFile.isFile()) {
+ if (!passwordFile.canRead()) {
+ throw logErrorAndCreateRuntimeException(
+ "'%s' password file exists, but cannot be read.", passwordFilePath);
+ }
+
+ long length = passwordFile.length();
+ if (length != PASSWORD_LENGTH_DEFAULT) {
+ throw logErrorAndCreateRuntimeException(
+ "'%s' password file exists but has an invalid length of %d bytes. The expected length is %d bytes.",
+ passwordFilePath, length, PASSWORD_LENGTH_DEFAULT);
+ }
+ return false;
+ }
+
+ byte[] token = generateToken();
+ try {
+ Files.write(passwordFilePath, token);
+ logger.info("Password was stored in {} file", passwordFilePath);
+ return true;
+ } catch (IOException e) {
+ throw logErrorAndCreateRuntimeException(e, "Password generation has failed");
+ }
+ }
+
+ private byte[] generateToken() {
+ SecureRandom random = new SecureRandom();
+ byte[] token = new byte[PASSWORD_LENGTH_DEFAULT];
+ random.nextBytes(token);
+ return token;
+ }
+
+ private IllegalStateException logErrorAndCreateRuntimeException(
+ String msg, Object... parameters) {
+ return logErrorAndCreateRuntimeException(null, msg, parameters);
+ }
+
+ private IllegalStateException logErrorAndCreateRuntimeException(
+ Exception e, String msg, Object... parameters) {
+ String log = String.format(msg, parameters);
+ if (e != null) {
+ logger.error(log, e);
+ return new IllegalStateException(log, e);
+ }
+
+ logger.error(log);
+ return new IllegalStateException(log);
+ }
+}
diff --git a/github-oauth/src/test/java/com/googlesource/gerrit/plugins/github/oauth/GitHubOAuthConfigTest.java b/github-oauth/src/test/java/com/googlesource/gerrit/plugins/github/oauth/GitHubOAuthConfigTest.java
index 94d1a43..a5bf767 100644
--- a/github-oauth/src/test/java/com/googlesource/gerrit/plugins/github/oauth/GitHubOAuthConfigTest.java
+++ b/github-oauth/src/test/java/com/googlesource/gerrit/plugins/github/oauth/GitHubOAuthConfigTest.java
@@ -15,12 +15,10 @@
import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.CONF_KEY_SECTION;
import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.CONF_SECTION;
-import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.CIPHER_ALGORITHM_DEFAULT;
import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.CIPHER_ALGO_CONFIG_LABEL;
import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.CURRENT_CONFIG_LABEL;
import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.KEY_DELIMITER;
-import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.KEY_ID_DEFAULT;
-import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.SECRET_KEY_ALGORITHM_DEFAULT;
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.PASSWORD_DEVICE_CONFIG_LABEL;
import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.SECRET_KEY_CONFIG_LABEL;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
@@ -38,6 +36,7 @@
CanonicalWebUrl canonicalWebUrl;
Config config;
+ private static final String testPasswordDevice = "/dev/zero";
@Before
public void setUp() {
@@ -61,28 +60,13 @@
}
@Test
- public void shouldDefaultKeyConfigWhenNoSpecificConfigurationIsSet() {
- GitHubOAuthConfig objectUnderTest = objectUnderTest();
- assertEquals(objectUnderTest.getCurrentKeyConfig().isCurrent(), true);
- assertEquals(
- objectUnderTest.getCurrentKeyConfig().getCipherAlgorithm(), CIPHER_ALGORITHM_DEFAULT);
- assertEquals(
- objectUnderTest.getCurrentKeyConfig().getSecretKeyAlgorithm(),
- SECRET_KEY_ALGORITHM_DEFAULT);
- assertEquals(objectUnderTest.getCurrentKeyConfig().getKeyId(), KEY_ID_DEFAULT);
- }
-
- @Test
- public void shouldDBeAbleToLookupDefaultKeyWhenNoSpecificConfigurationIsSet() {
- assertEquals(objectUnderTest().getKeyConfig(KEY_ID_DEFAULT).isCurrent(), true);
- }
-
- @Test
public void shouldReadASpecificKeyConfig() {
String keySubsection = "someKeyConfig";
String cipherAlgorithm = "AES/CFB8/NoPadding";
String secretKeyAlgorithm = "DES";
config.setBoolean(CONF_KEY_SECTION, keySubsection, CURRENT_CONFIG_LABEL, true);
+ config.setString(
+ CONF_KEY_SECTION, keySubsection, PASSWORD_DEVICE_CONFIG_LABEL, testPasswordDevice);
config.setString(CONF_KEY_SECTION, keySubsection, CIPHER_ALGO_CONFIG_LABEL, cipherAlgorithm);
config.setString(CONF_KEY_SECTION, keySubsection, SECRET_KEY_CONFIG_LABEL, secretKeyAlgorithm);
@@ -96,10 +80,16 @@
@Test
public void shouldReturnTheExpectedKeyConfigAsCurrent() {
- config.setBoolean(CONF_KEY_SECTION, "currentKeyConfig", CURRENT_CONFIG_LABEL, true);
- config.setBoolean(CONF_KEY_SECTION, "someOtherKeyConfig", CURRENT_CONFIG_LABEL, false);
+ String currentKeyConfig = "currentKeyConfig";
+ String someOtherKeyConfig = "someOtherKeyConfig";
+ config.setBoolean(CONF_KEY_SECTION, currentKeyConfig, CURRENT_CONFIG_LABEL, true);
+ config.setString(
+ CONF_KEY_SECTION, currentKeyConfig, PASSWORD_DEVICE_CONFIG_LABEL, testPasswordDevice);
+ config.setBoolean(CONF_KEY_SECTION, someOtherKeyConfig, CURRENT_CONFIG_LABEL, false);
+ config.setString(
+ CONF_KEY_SECTION, someOtherKeyConfig, PASSWORD_DEVICE_CONFIG_LABEL, testPasswordDevice);
- assertEquals(objectUnderTest().getCurrentKeyConfig().getKeyId(), "currentKeyConfig");
+ assertEquals(objectUnderTest().getCurrentKeyConfig().getKeyId(), currentKeyConfig);
}
@Test
@@ -107,7 +97,11 @@
String currentKeyConfig = "currentKeyConfig";
String someOtherKeyConfig = "someOtherKeyConfig";
config.setBoolean(CONF_KEY_SECTION, currentKeyConfig, CURRENT_CONFIG_LABEL, true);
+ config.setString(
+ CONF_KEY_SECTION, currentKeyConfig, PASSWORD_DEVICE_CONFIG_LABEL, testPasswordDevice);
config.setBoolean(CONF_KEY_SECTION, someOtherKeyConfig, CURRENT_CONFIG_LABEL, false);
+ config.setString(
+ CONF_KEY_SECTION, someOtherKeyConfig, PASSWORD_DEVICE_CONFIG_LABEL, testPasswordDevice);
GitHubOAuthConfig objectUnderTest = objectUnderTest();
@@ -116,6 +110,18 @@
}
@Test
+ public void shouldThrowWhenNoKeyIdIsConfigured() {
+ IllegalStateException illegalStateException =
+ assertThrows(IllegalStateException.class, this::objectUnderTest);
+
+ assertEquals(
+ illegalStateException.getMessage(),
+ String.format(
+ "Expected exactly 1 subsection of '%s' to be configured as 'current', %d found",
+ CONF_KEY_SECTION, 0));
+ }
+
+ @Test
public void shouldThrowWhenNoKeyConfigIsSetAsCurrent() {
config.setBoolean(CONF_KEY_SECTION, "someKeyConfig", CURRENT_CONFIG_LABEL, false);
@@ -145,6 +151,21 @@
assertThrows(IllegalStateException.class, this::objectUnderTest);
}
+ @Test
+ public void shouldThrowWhenKeyIdMissesPasswordDevice() {
+ String someKeyConfig = "someKeyConfig";
+ config.setBoolean(CONF_KEY_SECTION, someKeyConfig, CURRENT_CONFIG_LABEL, true);
+
+ IllegalStateException illegalStateException =
+ assertThrows(IllegalStateException.class, this::objectUnderTest);
+
+ assertEquals(
+ String.format(
+ "Configuration error. Missing %s.%s for key-id '%s'",
+ CONF_KEY_SECTION, PASSWORD_DEVICE_CONFIG_LABEL, someKeyConfig),
+ illegalStateException.getMessage());
+ }
+
private GitHubOAuthConfig objectUnderTest() {
return new GitHubOAuthConfig(config, canonicalWebUrl);
}
diff --git a/github-oauth/src/test/java/com/googlesource/gerrit/plugins/github/oauth/OAuthTokenCipherTest.java b/github-oauth/src/test/java/com/googlesource/gerrit/plugins/github/oauth/OAuthTokenCipherTest.java
index 9621fc5..3e31d6b 100644
--- a/github-oauth/src/test/java/com/googlesource/gerrit/plugins/github/oauth/OAuthTokenCipherTest.java
+++ b/github-oauth/src/test/java/com/googlesource/gerrit/plugins/github/oauth/OAuthTokenCipherTest.java
@@ -17,6 +17,7 @@
import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.CONF_SECTION;
import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.CIPHER_ALGO_CONFIG_LABEL;
import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.CURRENT_CONFIG_LABEL;
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.KEY_ID_DEFAULT;
import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.PASSWORD_DEVICE_CONFIG_LABEL;
import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.SECRET_KEY_CONFIG_LABEL;
import static com.googlesource.gerrit.plugins.github.oauth.OAuthTokenCipher.splitKeyIdFromMaterial;
@@ -31,32 +32,38 @@
import com.google.inject.util.Providers;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
import java.util.Base64;
import java.util.List;
import org.eclipse.jgit.lib.Config;
import org.junit.Before;
+import org.junit.ClassRule;
import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
public class OAuthTokenCipherTest {
CanonicalWebUrl canonicalWebUrl;
Config config;
+ @ClassRule public static TemporaryFolder temporaryFolder = new TemporaryFolder();
+
private static final String VERSION1_KEY_ID = "version1";
private static final String VERSION2_KEY_ID = "version2";
@Before
public void setUp() {
- config = new Config();
-
- config.setString(CONF_SECTION, null, "clientSecret", "theSecret");
- config.setString(CONF_SECTION, null, "clientId", "theClientId");
- config.setString("auth", null, "httpHeader", "GITHUB_USER");
- config.setString("auth", null, "type", AuthType.HTTP.toString());
+ config = createCommonConfig();
config.setBoolean(CONF_KEY_SECTION, VERSION1_KEY_ID, CURRENT_CONFIG_LABEL, true);
config.setBoolean(CONF_KEY_SECTION, VERSION2_KEY_ID, CURRENT_CONFIG_LABEL, false);
+ String testPasswordDevice = "/dev/zero";
+ config.setString(
+ CONF_KEY_SECTION, VERSION1_KEY_ID, PASSWORD_DEVICE_CONFIG_LABEL, testPasswordDevice);
+ config.setString(
+ CONF_KEY_SECTION, VERSION2_KEY_ID, PASSWORD_DEVICE_CONFIG_LABEL, testPasswordDevice);
+
canonicalWebUrl =
Guice.createInjector(
new AbstractModule() {
@@ -71,10 +78,32 @@
}
@Test
- public void shouldEncryptAndDecryptAToken() throws IOException {
- String someOauthToken = "someToken";
- OAuthTokenCipher objectUnderTest = objectUnderTest();
+ public void shouldEncryptAndDecryptATokenWithPasswordGeneratedAtInit() throws IOException {
+ // simulate plugin init step by generating a password to a file and configuring it in
+ // gerrit.config
+ Path passwordFilePath =
+ temporaryFolder.newFolder().toPath().resolve(PasswordGenerator.DEFAULT_PASSWORD_FILE);
+ new PasswordGenerator().generate(passwordFilePath);
+ config = createCommonConfig();
+ config.setBoolean(CONF_KEY_SECTION, KEY_ID_DEFAULT, CURRENT_CONFIG_LABEL, true);
+ config.setString(
+ CONF_KEY_SECTION,
+ KEY_ID_DEFAULT,
+ PASSWORD_DEVICE_CONFIG_LABEL,
+ passwordFilePath.toString());
+
+ verifyTokenEncryptionAndDecryption(objectUnderTest());
+ }
+
+ @Test
+ public void shouldEncryptAndDecryptAToken() throws IOException {
+ verifyTokenEncryptionAndDecryption(objectUnderTest());
+ }
+
+ private void verifyTokenEncryptionAndDecryption(OAuthTokenCipher objectUnderTest)
+ throws CipherException {
+ String someOauthToken = "someToken";
String encrypt = objectUnderTest.encrypt(someOauthToken);
assertNotEquals(encrypt, someOauthToken);
assertEquals(objectUnderTest.decrypt(encrypt), someOauthToken);
@@ -160,6 +189,19 @@
}
private OAuthTokenCipher objectUnderTest() throws IOException {
- return new OAuthTokenCipher(new GitHubOAuthConfig(config, canonicalWebUrl));
+ return objectUnderTest(config);
+ }
+
+ private OAuthTokenCipher objectUnderTest(Config testConfig) throws IOException {
+ return new OAuthTokenCipher(new GitHubOAuthConfig(testConfig, canonicalWebUrl));
+ }
+
+ private static Config createCommonConfig() {
+ Config config = new Config();
+ config.setString(CONF_SECTION, null, "clientSecret", "theSecret");
+ config.setString(CONF_SECTION, null, "clientId", "theClientId");
+ config.setString("auth", null, "httpHeader", "GITHUB_USER");
+ config.setString("auth", null, "type", AuthType.HTTP.toString());
+ return config;
}
}
diff --git a/github-oauth/src/test/java/com/googlesource/gerrit/plugins/github/oauth/PasswordGeneratorTest.java b/github-oauth/src/test/java/com/googlesource/gerrit/plugins/github/oauth/PasswordGeneratorTest.java
new file mode 100644
index 0000000..0fe37a8
--- /dev/null
+++ b/github-oauth/src/test/java/com/googlesource/gerrit/plugins/github/oauth/PasswordGeneratorTest.java
@@ -0,0 +1,92 @@
+// Copyright (C) 2022 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.googlesource.gerrit.plugins.github.oauth;
+
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.PASSWORD_LENGTH_DEFAULT;
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+public class PasswordGeneratorTest {
+ @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ private Path passwordPath;
+ private final PasswordGenerator objectUnderTest = new PasswordGenerator();
+
+ @Before
+ public void setup() throws IOException {
+ passwordPath =
+ temporaryFolder.newFolder().toPath().resolve(PasswordGenerator.DEFAULT_PASSWORD_FILE);
+ }
+
+ @Test
+ public void shouldGenerateKeyFileWithPasswordDefaultLength() throws IOException {
+ assertTrue(objectUnderTest.generate(passwordPath));
+ assertTrue(Files.isRegularFile(passwordPath));
+
+ byte[] token = Files.readAllBytes(passwordPath);
+ assertEquals(
+ String.format(
+ "Generated password length doesn't equal to expected %d", PASSWORD_LENGTH_DEFAULT),
+ token.length,
+ PASSWORD_LENGTH_DEFAULT);
+ }
+
+ @Test
+ public void shouldNotGenerateNewDefaultKeyIfOneAlreadyExistAndIsNotEmpty() throws IOException {
+ assertTrue(objectUnderTest.generate(passwordPath));
+ byte[] expected = Files.readAllBytes(passwordPath);
+ assertFalse(objectUnderTest.generate(passwordPath));
+ byte[] token = Files.readAllBytes(passwordPath);
+ assertArrayEquals("Existing password file was overwritten", expected, token);
+ }
+
+ @Test
+ public void shouldGenerateDifferentContentForDifferentSites() throws IOException {
+ assertTrue(objectUnderTest.generate(passwordPath));
+ byte[] siteA = Files.readAllBytes(passwordPath);
+
+ assertTrue(passwordPath.toFile().delete());
+ assertTrue(objectUnderTest.generate(passwordPath));
+ byte[] siteB = Files.readAllBytes(passwordPath);
+ assertFalse(
+ "The same password was generated for two different sites", Arrays.equals(siteA, siteB));
+ }
+
+ @Test
+ public void shouldThrowIllegalStateExceptionWhenDefaultKeyIsDirectory() throws IOException {
+ // create dir from passwordPath
+ assertTrue(passwordPath.toFile().mkdir());
+
+ IllegalStateException illegalStateException =
+ assertThrows(IllegalStateException.class, () -> objectUnderTest.generate(passwordPath));
+
+ assertTrue(
+ illegalStateException
+ .getMessage()
+ .endsWith("is directory whilst a regular file was expected."));
+ }
+}
diff --git a/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/InitGitHub.java b/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/InitGitHub.java
index 828613e..894e1a5 100644
--- a/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/InitGitHub.java
+++ b/github-plugin/src/main/java/com/googlesource/gerrit/plugins/github/InitGitHub.java
@@ -13,15 +13,35 @@
// limitations under the License.
package com.googlesource.gerrit.plugins.github;
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.CONF_KEY_SECTION;
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.CIPHER_ALGORITHM_DEFAULT;
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.CIPHER_ALGO_CONFIG_LABEL;
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.CURRENT_CONFIG_LABEL;
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.IS_CURRENT_DEFAULT;
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.KEY_ID_DEFAULT;
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.PASSWORD_DEVICE_CONFIG_LABEL;
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.PASSWORD_LENGTH_CONFIG_LABEL;
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.PASSWORD_LENGTH_DEFAULT;
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.SECRET_KEY_ALGORITHM_DEFAULT;
+import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.SECRET_KEY_CONFIG_LABEL;
+import static com.googlesource.gerrit.plugins.github.oauth.PasswordGenerator.DEFAULT_PASSWORD_FILE;
+
import com.google.common.base.Strings;
+import com.google.gerrit.extensions.annotations.PluginName;
import com.google.gerrit.extensions.client.AuthType;
import com.google.gerrit.pgm.init.api.ConsoleUI;
+import com.google.gerrit.pgm.init.api.InitFlags;
import com.google.gerrit.pgm.init.api.InitStep;
import com.google.gerrit.pgm.init.api.InitUtil;
import com.google.gerrit.pgm.init.api.Section;
+import com.google.gerrit.server.config.SitePaths;
import com.google.inject.Inject;
+import com.googlesource.gerrit.plugins.github.oauth.PasswordGenerator;
import java.net.URISyntaxException;
+import java.nio.file.Path;
import java.util.EnumSet;
+import java.util.Optional;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
public class InitGitHub implements InitStep {
private static final String GITHUB_URL = "https://github.com";
@@ -29,15 +49,26 @@
private static final String GITHUB_REGISTER_APPLICATION_PATH = "/settings/applications/new";
private static final String GERRIT_OAUTH_CALLBACK_PATH = "oauth";
+ private final Path pluginData;
private final ConsoleUI ui;
private final Section auth;
private final Section httpd;
private final Section github;
private final Section gerrit;
+ private final Section.Factory sections;
+ private final FileBasedConfig cfg;
@Inject
- InitGitHub(final ConsoleUI ui, final Section.Factory sections) {
+ InitGitHub(
+ @PluginName String pluginName,
+ SitePaths site,
+ final ConsoleUI ui,
+ final Section.Factory sections,
+ InitFlags flags) {
+ this.pluginData = site.data_dir.resolve(pluginName);
this.ui = ui;
+ this.sections = sections;
+ this.cfg = flags.cfg;
this.github = sections.get("github", null);
this.httpd = sections.get("httpd", null);
this.auth = sections.get("auth", null);
@@ -85,6 +116,87 @@
httpd.unset("filterClass");
httpd.unset("httpHeader");
}
+
+ setupGitHubOAuthTokenCipher();
+ }
+
+ private void setupGitHubOAuthTokenCipher() {
+ ui.header("GitHub OAuth token cipher configuration");
+ Optional<String> maybeCurrentKeyId = getCurrentKeyId();
+
+ boolean configureNewPasswordDevice = false;
+ if (maybeCurrentKeyId.isPresent()) {
+ if (!ui.yesno(
+ false,
+ "Current GitHub OAuth token cipher is configured under the %s key id. Do you want to configure a new one?",
+ maybeCurrentKeyId.get())) {
+ return;
+ }
+
+ String notUniqueKeyIdPrefix = "K";
+ do {
+ String newKeyId = ui.readString("some-key-id", "%sey identifier", notUniqueKeyIdPrefix);
+ if (cfg.getSubsections(CONF_KEY_SECTION).contains(newKeyId)) {
+ notUniqueKeyIdPrefix =
+ String.format(
+ "Provided key id '%s' already exists. Please provide a different key.", newKeyId);
+ continue;
+ }
+
+ cfg.setBoolean(CONF_KEY_SECTION, maybeCurrentKeyId.get(), CURRENT_CONFIG_LABEL, false);
+ maybeCurrentKeyId = Optional.of(newKeyId);
+ configureNewPasswordDevice = true;
+ break;
+ } while (true);
+ }
+
+ String currentKeyId = maybeCurrentKeyId.orElse(KEY_ID_DEFAULT);
+ ui.message("Configuring GitHub OAuth token cipher under '%s' key id\n", currentKeyId);
+ Section gitHubKey = sections.get(CONF_KEY_SECTION, currentKeyId);
+
+ gitHubKey.set(CURRENT_CONFIG_LABEL, "true");
+
+ Path defaultPasswordPath = pluginData.resolve(DEFAULT_PASSWORD_FILE);
+ String currentPasswordPath =
+ gitHubKey.string(
+ "Password file or device",
+ PASSWORD_DEVICE_CONFIG_LABEL,
+ configureNewPasswordDevice ? null : defaultPasswordPath.toString());
+ if (defaultPasswordPath.toString().equalsIgnoreCase(currentPasswordPath)) {
+ pluginData.toFile().mkdirs();
+ if (new PasswordGenerator().generate(Path.of(currentPasswordPath))) {
+ ui.message(
+ "New password (%d bytes long) was generated under '%s' file.\n",
+ PASSWORD_LENGTH_DEFAULT, currentPasswordPath);
+ } else {
+ ui.message(
+ "The file under '%s' path already exists. Password wasn't regenerated.\n",
+ currentPasswordPath);
+ }
+ } else {
+ // ask for length only if default password is not used
+ gitHubKey.set(
+ PASSWORD_LENGTH_CONFIG_LABEL,
+ String.valueOf(ui.readInt(PASSWORD_LENGTH_DEFAULT, "Password length in bytes")));
+ }
+
+ gitHubKey.string(
+ "The algorithm to be used to encrypt the provided password",
+ SECRET_KEY_CONFIG_LABEL,
+ SECRET_KEY_ALGORITHM_DEFAULT);
+
+ gitHubKey.string(
+ "The algorithm to be used for encryption/decryption",
+ CIPHER_ALGO_CONFIG_LABEL,
+ CIPHER_ALGORITHM_DEFAULT);
+ }
+
+ private Optional<String> getCurrentKeyId() {
+ return cfg.getSubsections(CONF_KEY_SECTION).stream()
+ .filter(
+ keyId ->
+ cfg.getBoolean(CONF_KEY_SECTION, keyId, CURRENT_CONFIG_LABEL, IS_CURRENT_DEFAULT))
+ .findFirst();
}
private void authSetDefault(String key, String defValue) {
diff --git a/github-plugin/src/main/resources/Documentation/config.md b/github-plugin/src/main/resources/Documentation/config.md
index ea32890..e2ea0a3 100644
--- a/github-plugin/src/main/resources/Documentation/config.md
+++ b/github-plugin/src/main/resources/Documentation/config.md
@@ -7,7 +7,8 @@
library to work properly.
GitHub OAuth library rely on Gerrit HTTP authentication defined during the standard
-Gerrit init steps.
+Gerrit init steps. It also requires GitHub OAuth token cipher configuration
+(details `Key configuration` section) but provides convenient defaults for it.
See below a sample session of relevant init steps for a default
configuration pointing to the Web GitHub instance:
@@ -36,6 +37,15 @@
ClientSecret []: f82c3f9b3802666f2adcc4c8cacfb164295b0a99
confirm password :
HTTP Authentication Header [GITHUB_USER]:
+
+ *** GitHub OAuth token cipher configuration
+ ***
+
+ Configuring GitHub OAuth token cipher under 'current' key id
+ Password file or device [gerrit/data/github-plugin/default.key]:
+ New password (16 bytes long) was generated under 'gerrit/data/github-plugin/default.key' file.
+ The algorithm to be used to encrypt the provided password [AES]:
+ The algorithm to be used for encryption/decryption [AES/ECB/PKCS5Padding]:
```
Configuration
@@ -89,15 +99,14 @@
-------------
Since this plugin obtains credentials from Github and persists them in Gerrit,
-it also takes care of encrypting them at rest. The Gerrit admin can configure
-how this is done by setting the relevant configuration parameters.
+it also takes care of encrypting them at rest. Encryption configuration is a
+mandatory step performed during the plugin init (that also provides convenient
+defaults). The Gerrit admin can introduce its own cipher configuration
+(already in init step) by setting the following parameters.
github-key.<key-id>.passwordDevice
: The device or file where to retrieve the encryption passphrase.\
-Default: /dev/zero
-
-*NOTE*: such configuration is considered insecure and should *not be used in
-production*, always set a non-zero password device for deriving the key.
+This is a required parameter for `key-id` configuration.
github-key.<key-id>.passwordLength
: The length in bytes of the password read from the passwordDevice.\
@@ -111,7 +120,7 @@
Default: AES/ECB/PKCS5Padding
github-key.<key-id>.secretKeyAlgorithm
-: the algorithm to be used to encrypt the provided password. Available
+: The algorithm to be used to encrypt the provided password. Available
algorithms are described in
the [Cipher section](https://docs.oracle.com/en/java/javase/11/docs/specs/security/standard-names.html#cipher-algorithm-names)
of the Java Cryptography Architecture Standard Algorithm Name Documentation.\
@@ -129,14 +138,12 @@
in the configuration. New credentials will always be encrypted with
the `current` `<key-id>`.
-Note that unencrypted oauth tokens will be handled gracefully and just passed
-through to github by the decryption algorithm. This is done so that oauth tokens
-that were persisted _before_ the encryption feature was implemented will still
-be considered valid until their natural expiration time.
+*Notes:*
+Unencrypted oauth tokens will be handled gracefully and just passed through to
+github by the decryption algorithm. This is done so that oauth tokens that were
+persisted _before_ the encryption feature was implemented will still be
+considered valid until their natural expiration time.
-If no `github-key.<key-id>` exists in configuration, then a default current key
-configuration
-(named `current`) will be inferred, using the defaults documented above.
-
-*NOTE* such configuration is considered insecure and should *not be used in
-production*.
\ No newline at end of file
+Plugin will not start if no `github-key.<key-id>` section, marked as current,
+exists in configuration. One needs to either configure it manually or call init
+for a default configuration to be created.