For anyone who might be interested in a solution for this problem, I was able to get things working mostly as I had hoped. Rather than using a randomly-generated security, I have used a configurable password-based encryption scheme. Once I accepted that approach for my problem I was able to make things work very well.
First, here is the code I am using to create my password-based secret key for encrypting the private key:
private SecretKey createSecretKey() throws MyCryptoException {
try {
String password = getPassword(); // Retrieved via configuration
KeySpec keySpec = new PBEKeySpec(password.toCharArray());
SecretKeyFactory factory = SecretKeyFactory.getInstance(this.encryptionAlgorithm.getName());
return factory.generateSecret(keySpec);
}
catch (GeneralSecurityException e) {
throw new MyCryptoException("Error creating secret key", e);
}
}
To create the cipher I use for encrypting:
private Cipher createCipher() throws MyCryptoException {
try {
return Cipher.getInstance(this.encryptionAlgorithm.getName());
}
catch (GeneralSecurityException e) {
throw new MyCryptoException("Error creating cipher for password-based encryption", e);
}
}
For the above method, this.encryptionAlgorithm.getName()
will return either PBEWithMD5AndDES
or PBEWithSHA1AndDESede
. These appear to be consistent with PKCS #5 version 1.5 password-based encryption (PBKDF1). I eventually plan to work on supporting newer (and more secure) versions of this, but this gets the job done for now.
Next, I need a password-based parameter specification:
private AlgorithmParameterSpec createParamSpec() {
byte[] saltVector = new byte[this.encryptionAlgorithm.getSaltSize()];
SecureRandom random = new SecureRandom();
random.nextBytes(saltVector);
return new PBEParameterSpec(saltVector, this.encryptionHashIterations);
}
In the above method, this.encryptionAlgorithm.getSaltSize()
returns either 8 or 16, depending on which algorithm name is configured.
Then, I pull these methods together to convert the private key's bytes into a java.crypto.EncryptedPrivateKeyInfo
instance
public EncryptedPrivateKeyInfo encryptPrivateKey(byte[] keyBytes) throws MyCryptoException {
// Create cipher and encrypt
byte[] encryptedBytes;
AlgorithmParameters parameters;
try {
Cipher cipher = createCipher();
SecretKey encryptionKey = createSecretKey();
AlgorithmParameterSpec paramSpec = createParamSpec();
cipher.init(Cipher.ENCRYPT_MODE, encryptionKey, paramSpec);
encryptedBytes = cipher.doFinal(keyBytes);
parameters = cipher.getParameters();
}
catch (GeneralSecurityException e) {
throw new MyCryptoException("Error encrypting private key bytes", e);
}
// Wrap into format expected for PKCS8-formatted encrypted secret key file
try {
return new EncryptedPrivateKeyInfo(parameters, encryptedBytes);
}
catch (GeneralSecurityException e) {
throw new MyCryptoException("Error packaging private key encryption info", e);
}
}
This EncryptedPrivateKeyInfo
instance is what gets written to the file, Base64 encoded and surrounded with the appropriate header and footer text. The following shows how I use the above method to create an encrypted key file:
private static final String ENCRYPTED_KEY_HEADER = "-----BEGIN ENCRYPTED PRIVATE KEY-----";
private static final String ENCRYPTED_KEY_FOOTER = "-----END ENCRYPTED PRIVATE KEY-----";
private static final int KEY_FILE_MAX_LINE_LENGTH = 64;
private void writePrivateKey(PrivateKey key, Path path) throws MyCryptoException {
try {
byte[] fileBytes = key.getEncoded();
encryptPrivateKey(key.getEncoded()).getEncoded();
writeKeyFile(ENCRYPTED_KEY_HEADER, ENCRYPTED_KEY_FOOTER, fileBytes, path);
}
catch (IOException e) {
throw new MyCryptoException("Can't write private key file", e);
}
}
private void writeKeyFile(String header, String footer, byte[] keyBytes, Path path) throws IOException {
// Append the header
StringBuilder builder = new StringBuilder()
.append(header)
.append(System.lineSeparator());
// Encode the key and append lines according to the max line size
String encodedBytes = Base64.getEncoder().encodeToString(keyBytes);
partitionBySize(encodedBytes, KEY_FILE_MAX_LINE_LENGTH)
.stream()
.forEach(s -> {
builder.append(s);
builder.append(System.lineSeparator());
});
// Append the footer
builder
.append(footer)
.append(System.lineSeparator());
// Write the file
Files.writeString(path, builder.toString());
}
private List<String> partitionBySize(String source, int size) {
int sourceLength = source.length();
boolean isDivisible = (sourceLength % size) == 0;
int partitionCount = (sourceLength / size) + (isDivisible ? 0 : 1);
return IntStream.range(0, partitionCount)
.mapToObj(n -> {
return ((n + 1) * size >= sourceLength) ?
source.substring(n * size) : source.substring(n * size, (n + 1) * size);
})
.collect(Collectors.toList());
}