Skip to content

Commit

Permalink
[JENKINS-58743] Allow to provide a custom path for master key (jenkin…
Browse files Browse the repository at this point in the history
  • Loading branch information
krisstern authored Feb 6, 2025
2 parents 092756b + 047e875 commit 314930c
Show file tree
Hide file tree
Showing 3 changed files with 72 additions and 12 deletions.
3 changes: 3 additions & 0 deletions core/src/main/java/jenkins/model/Jenkins.java
Original file line number Diff line number Diff line change
Expand Up @@ -974,6 +974,9 @@ protected Jenkins(File root, ServletContext context, PluginManager pluginManager
ClassFilterImpl.register();
LOGGER.info("Starting version " + getVersion());

// Sanity check that we can load the confidential store. Fail fast if we can't.
ConfidentialStore.get();

// initialization consists of ...
executeReactor(is,
pluginManager.initTasks(is), // loading and preparing plugins
Expand Down
37 changes: 31 additions & 6 deletions core/src/main/java/jenkins/security/DefaultConfidentialStore.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package jenkins.security;

import edu.umd.cs.findbugs.annotations.NonNull;
import hudson.FilePath;
import hudson.Util;
import hudson.util.Secret;
Expand All @@ -19,19 +20,35 @@
import javax.crypto.CipherOutputStream;
import javax.crypto.SecretKey;
import jenkins.model.Jenkins;
import jenkins.util.SystemProperties;

/**
* Default portable implementation of {@link ConfidentialStore} that uses
* a directory inside $JENKINS_HOME.
*
* The master key is also stored in this same directory.
* <p>
* The master key is stored by default in <code>$JENKINS_HOME/secrets/master.key</code> but another location can be provided using the system property <code>jenkins.master.key.file</code>.
* <p>
* It is also possible to prevent the generation of the master key file using the system property <code>-Djenkins.master.key.readOnly</code>. In this case, the master key file must be provided or startup will fail.
*
* @author Kohsuke Kawaguchi
*/
// @MetaInfServices --- not annotated because this is the fallback implementation
public class DefaultConfidentialStore extends ConfidentialStore {
static final String MASTER_KEY_FILE_SYSTEM_PROPERTY = DefaultConfidentialStore.class.getName() + ".file";
static final String MASTER_KEY_READONLY_SYSTEM_PROPERTY_NAME = DefaultConfidentialStore.class.getName() + ".readOnly";

private final SecureRandom sr = new SecureRandom();

@NonNull
private static File getMasterKeyFile(File rootDir) {
var jenkinsMasterKey = SystemProperties.getString(MASTER_KEY_FILE_SYSTEM_PROPERTY);
if (jenkinsMasterKey != null) {
return new File(jenkinsMasterKey);
} else {
return new File(rootDir, "master.key");
}
}

/**
* Directory that stores individual keys.
*/
Expand All @@ -51,18 +68,26 @@ public DefaultConfidentialStore() throws IOException, InterruptedException {
}

public DefaultConfidentialStore(File rootDir) throws IOException, InterruptedException {
this(rootDir, getMasterKeyFile(rootDir));
}

protected DefaultConfidentialStore(File rootDir, File keyFile) throws IOException, InterruptedException {
this.rootDir = rootDir;
if (rootDir.mkdirs()) {
// protect this directory. but don't change the permission of the existing directory
// in case the administrator changed this.
new FilePath(rootDir).chmod(0700);
}

TextFile masterSecret = new TextFile(new File(rootDir, "master.key"));
TextFile masterSecret = new TextFile(keyFile);
if (!masterSecret.exists()) {
// we are only going to use small number of bits (since export control limits AES key length)
// but let's generate a long enough key anyway
masterSecret.write(Util.toHexString(randomBytes(128)));
if (SystemProperties.getBoolean(MASTER_KEY_READONLY_SYSTEM_PROPERTY_NAME)) {
throw new IOException(masterSecret + " does not exist and system property " + MASTER_KEY_READONLY_SYSTEM_PROPERTY_NAME + " is set. You must provide a valid master key file.");
} else {
// we are only going to use small number of bits (since export control limits AES key length)
// but let's generate a long enough key anyway
masterSecret.write(Util.toHexString(randomBytes(128)));
}
}
this.masterKey = Util.toAes128Key(masterSecret.readTrim());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,14 @@

import hudson.FilePath;
import hudson.Functions;
import hudson.Util;
import hudson.util.TextFile;
import java.io.File;
import java.io.IOException;
import java.nio.charset.MalformedInputException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.security.SecureRandom;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
Expand All @@ -20,33 +24,61 @@ public class DefaultConfidentialStoreTest {
@Rule
public TemporaryFolder tmpRule = new TemporaryFolder();

private final SecureRandom sr = new SecureRandom();

@Test
public void roundtrip() throws Exception {
File tmp = new File(tmpRule.getRoot(), "tmp"); // let ConfidentialStore create a directory

DefaultConfidentialStore store = new DefaultConfidentialStore(tmp);
ConfidentialKey key = new ConfidentialKey("test") {};

assertTrue(new File(tmp, "master.key").exists());
roundTrip(store, key, tmp);

// if the master key changes, we should gracefully fail to load the store
new File(tmp, "master.key").delete();
DefaultConfidentialStore store2 = new DefaultConfidentialStore(tmp);
assertTrue(new File(tmp, "master.key").exists()); // we should have a new key now
assertNull(store2.load(key));
}

private static void roundTrip(DefaultConfidentialStore store, ConfidentialKey key, File tmp) throws IOException, InterruptedException {
// basic roundtrip
String str = "Hello world!";
store.store(key, str.getBytes(StandardCharsets.UTF_8));
assertEquals(str, new String(store.load(key), StandardCharsets.UTF_8));

// data storage should have some stuff
assertTrue(new File(tmp, "test").exists());
assertTrue(new File(tmp, "master.key").exists());

assertThrows(MalformedInputException.class, () -> Files.readString(tmp.toPath().resolve("test"), StandardCharsets.UTF_8)); // the data shouldn't be a plain text, obviously

if (!Functions.isWindows()) {
assertEquals(0700, new FilePath(tmp).mode() & 0777); // should be read only
}
}

// if the master key changes, we should gracefully fail to load the store
new File(tmp, "master.key").delete();
DefaultConfidentialStore store2 = new DefaultConfidentialStore(tmp);
assertTrue(new File(tmp, "master.key").exists()); // we should have a new key now
assertNull(store2.load(key));
@Test
public void masterKeyGeneratedBeforehand() throws IOException, InterruptedException {
File external = new File(tmpRule.getRoot(), "external");
File tmp = new File(tmpRule.getRoot(), "tmp");
var masterKeyFile = new File(external, "master.key");
new TextFile(masterKeyFile).write(Util.toHexString(randomBytes(128)));
System.setProperty(DefaultConfidentialStore.MASTER_KEY_FILE_SYSTEM_PROPERTY, masterKeyFile.getAbsolutePath());
System.setProperty(DefaultConfidentialStore.MASTER_KEY_READONLY_SYSTEM_PROPERTY_NAME, "true");
DefaultConfidentialStore store = new DefaultConfidentialStore(tmp);
ConfidentialKey key = new ConfidentialKey("test") {};
roundTrip(store, key, tmp);
// With this configuration, the master key file deletion is fatal
masterKeyFile.delete();
assertThrows(IOException.class, () -> new DefaultConfidentialStore(tmp));
}

private byte[] randomBytes(int size) {
byte[] random = new byte[size];
sr.nextBytes(random);
return random;
}

}

0 comments on commit 314930c

Please sign in to comment.