Created
March 10, 2020 18:33
-
-
Save jvz/dc62641b56728100123d8d0d8110945a to your computer and use it in GitHub Desktop.
Incomplete proof of concept for https://issues.jenkins-ci.org/browse/JENKINS-61406
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package io.jenkins.plugins.keystore; | |
import hudson.init.InitMilestone; | |
import hudson.init.Initializer; | |
import jenkins.model.Jenkins; | |
import jenkins.security.ConfidentialKey; | |
import jenkins.security.ConfidentialStore; | |
import org.apache.commons.io.IOUtils; | |
import javax.annotation.CheckForNull; | |
import javax.crypto.Cipher; | |
import javax.crypto.CipherInputStream; | |
import javax.crypto.CipherOutputStream; | |
import javax.crypto.NoSuchPaddingException; | |
import javax.crypto.SecretKey; | |
import javax.crypto.SecretKeyFactory; | |
import javax.crypto.spec.PBEKeySpec; | |
import java.io.Console; | |
import java.io.IOException; | |
import java.io.UncheckedIOException; | |
import java.nio.file.Files; | |
import java.nio.file.Path; | |
import java.nio.file.StandardOpenOption; | |
import java.nio.file.attribute.PosixFilePermissions; | |
import java.security.InvalidKeyException; | |
import java.security.NoSuchAlgorithmException; | |
import java.security.SecureRandom; | |
import java.security.spec.InvalidKeySpecException; | |
import java.security.spec.KeySpec; | |
import java.util.Arrays; | |
import java.util.Base64; | |
import java.util.Collections; | |
public class PasswordProtectedConfidentialStore extends ConfidentialStore { | |
@Initializer(after = InitMilestone.PLUGINS_STARTED) | |
public static void init() { | |
Jenkins.get().lookup.set(ConfidentialStore.class, new PasswordProtectedConfidentialStore()); | |
} | |
// TODO: ideally, this would use Argon2, bcrypt, or scrypt, and AES-256 (or ChaCha20) | |
private static final String ALGORITHM = "PBEWithHmacSHA512AndAES_256"; | |
private static final int ITERATION_COUNT = 12288; | |
private static final int KEY_LENGTH = 256 * 8; | |
private final Path root; | |
private final SecureRandom random; | |
private final SecretKey masterKey; | |
public PasswordProtectedConfidentialStore() { | |
try { | |
root = Jenkins.get().root.toPath().resolve("secrets"); | |
if (Files.notExists(root)) { | |
Files.createDirectory(root, | |
PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); | |
} | |
random = SecureRandom.getInstanceStrong(); | |
masterKey = readKey(); | |
} catch (NoSuchAlgorithmException e) { | |
throw new IllegalStateException("No support for password-based encryption", e); | |
} catch (InvalidKeySpecException e) { | |
throw new IllegalArgumentException(e); | |
} catch (IOException e) { | |
throw new UncheckedIOException(e); | |
} | |
} | |
private SecretKey readKey() throws NoSuchAlgorithmException, InvalidKeySpecException, IOException { | |
Console console = System.console(); | |
if (console == null) { | |
throw new IllegalStateException("No console available to unlock Jenkins"); | |
} | |
char[] password; | |
byte[] salt; | |
Path saltFile = root.resolve("master.salt"); | |
if (Files.exists(saltFile)) { | |
password = console.readPassword("Enter master key password to unlock Jenkins: "); | |
String saltString = Files.readAllLines(saltFile).get(0); | |
salt = Base64.getDecoder().decode(saltString); | |
} else { | |
do { | |
password = console.readPassword("Enter new master key password to encrypt secrets: "); | |
} while (!Arrays.equals(password, console.readPassword("Re-enter password: "))); | |
salt = new byte[128]; | |
random.nextBytes(salt); | |
String saltString = Base64.getEncoder().encodeToString(salt); | |
Files.write(saltFile, Collections.singleton(saltString), StandardOpenOption.CREATE_NEW); | |
} | |
SecretKeyFactory keyFactory = SecretKeyFactory.getInstance(ALGORITHM); | |
// iterations should be at least 10000 | |
// TODO: need a way to verify this was the correct password before we go silently decrypting and encrypting | |
// everything incorrectly | |
KeySpec spec = new PBEKeySpec(password, salt, ITERATION_COUNT, KEY_LENGTH); | |
return keyFactory.generateSecret(spec); | |
} | |
@Override | |
protected void store(ConfidentialKey key, byte[] payload) throws IOException { | |
Path keyFile = root.resolve(key.getId()); | |
try { | |
Cipher cipher = Cipher.getInstance(ALGORITHM); | |
cipher.init(Cipher.ENCRYPT_MODE, masterKey); | |
try (CipherOutputStream out = new CipherOutputStream(Files.newOutputStream(keyFile), cipher)) { | |
out.write(payload); | |
} | |
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException e) { | |
throw new IOException(e); | |
} | |
} | |
@CheckForNull | |
@Override | |
protected byte[] load(ConfidentialKey key) throws IOException { | |
Path keyFile = root.resolve(key.getId()); | |
if (Files.notExists(keyFile)) { | |
return null; | |
} | |
try { | |
Cipher cipher = Cipher.getInstance(ALGORITHM); | |
cipher.init(Cipher.DECRYPT_MODE, masterKey); | |
try (CipherInputStream in = new CipherInputStream(Files.newInputStream(keyFile), cipher)) { | |
return IOUtils.toByteArray(in); | |
} | |
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException e) { | |
throw new IOException(e); | |
} | |
} | |
@Override | |
public byte[] randomBytes(int size) { | |
if (size > 100000) { | |
throw new IllegalArgumentException("Too much random: " + size); | |
} | |
byte[] buf = new byte[size]; | |
random.nextBytes(buf); | |
return buf; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
See https://github.com/jvz/keystore-module for updated version.