0

I have a Java service that will generate an ECDSA public/private key pair. I'd like to write both the public key and the private key, encrypted with a randomly-generated secret key that I manage within my service, to the local file system.

I could obviously just encode the keys' bytes using base64 and write that out to a file, or I could write them in a binary format of my own creation. But if possible, I'd prefer to write them out in a standardized format such as PEM or DER. I can figure that out for the unencrypted public key, but I'm struggling to figure out how to do it from within Java for the encrypted private key.

I know that I could call out into the OS and call openssl on the command line, but (a) I'd rather do this natively in Java, and (b) I've read numerous posts suggesting that openssl's algorithm for encoding the key is not particularly secure. So I am hoping to use the Java Cryptography Architecture (JCA) APIs to encrypt the private key using an algorithm of my choosing, and then to wrap the encrypted bytes in whatever is needed to make this a valid PEM- or DER-formatted file.

I suspect that there are libraries like BouncyCastle that make this easier, and I may use such a library if necessary. But my company deals in regulated software that places an ongoing bureaucratic maintenance cost for all off-the-shelf (OTS) software, so the ideal solution would be something that I can write directly in Java using the standard JCA classes (currently using Java 11).

I'd appreciate any thoughts and recommendations on how I might approach this problem.

Tim Dean
  • 8,253
  • 2
  • 32
  • 59
  • As the structure of an encrypted ec private key is not so difficult it is been able to read all necessary data from the "header" part (number of iterations, salt and IV) to run your own (nowadays) PBKDF2 key derivation, followed by a (e.g.) AES-CBC-256 decryption of the (encrypted) key data and receive the same data as the unencrypted (encoded) key has. To write an encrypted key you take a passphrase and iterations, generate random salt + IV, generate an encryption key and encrypt the (encoded) private key. – Michael Fehr Feb 28 '21 at 16:51
  • In the end you need to add a header, encoded the complete structure with Base64-Mime and prepend and append a PEM header & footer and you get an exchangeable encrypted EC private key. – Michael Fehr Feb 28 '21 at 16:52
  • After looking at the source code for the BouncyCastle utilities, it seems like I would be to add a PEM-formatted header that includes just the encryption algorithm name and the, written as hex, in the DEK-Info header. Unless I am missing something... – Tim Dean Mar 01 '21 at 19:13

2 Answers2

0

As long as you stay within Java (I mean that you don't want to exchange the (encrypted) private key with other systems) I recommend to encrypt the encoded private key - this way you stay completely with Java's built-in resources. Trying to use "encrypted PEM formats" requires to use an external library like Bouncy Castle.

The following solution will generate an ECDSA key pair and prints out the encoded private key. This byte array is encrypted using a randomly generated (32 bytes long) key that is used as input for an AES in GCM-mode operating function; the output is a string that in concatenated of 3 parts:

(Base64) nonce : (Base64) ciphertext : (Base64) gcmTag 

An optimized version could use the direct concatenation on byte array basis but as the function were taken from an actual project I'm using it this way.

I'm omitting the save & load part of the string - this string is provided to the decryption function that directly gives the (loaded) private key as output. This load key is printed out as well to show that both keys are equal.

This is the sample output:

Write and read encrypted ECDSA private keys
ecdsaPrivateKey:
3041020100301306072a8648ce3d020106082a8648ce3d030107042730250201010420c07c0af37716b11ac76780287026935190cb3575c1475a02da687b45adfed8b4
encryptedKey: 2adcp+3lEvS8zhc5:5n5UyHThiIQweqXxJfI479qIwv4m7nm/gNeEDeXcd15zVQCTuER2Hn/SPQUM9TbPFHkdh9CWwYI74lbCyV1AJng62g==:HRWiBgME/SsyHQBvvfdTEg==
ecdsaPrivateKeyLoaded:
3041020100301306072a8648ce3d020106082a8648ce3d030107042730250201010420c07c0af37716b11ac76780287026935190cb3575c1475a02da687b45adfed8b4

The following code has no exception handling and is for educational purpose only:

import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.security.*;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;

public class EncryptedEcdsaPrivateKey {
    public static void main(String[] args)
            throws NoSuchAlgorithmException, IllegalBlockSizeException, InvalidKeyException,
            BadPaddingException, InvalidAlgorithmParameterException, NoSuchPaddingException,
            InvalidKeySpecException {
        System.out.println("Write and read encrypted ECDSA private keys");
        // step 1 generate an ecdsa key pair
        KeyPair ecdsaKeyPair = generateEcdsaKeyPair(256);
        PrivateKey ecdsaPrivateKey = ecdsaKeyPair.getPrivate();
        System.out.println("ecdsaPrivateKey:\n" + bytesToHex(ecdsaPrivateKey.getEncoded()));
        // step 2 generate a randomly generated AES-256-GCM key
        byte[] randomKey = generateRandomAesKey();
        // step 3 encrypt the encoded key with the randomly generated AES-256-GCM key
        String encryptedKey = aesGcmEncryptToBase64(randomKey, ecdsaPrivateKey.getEncoded());
        System.out.println("encryptedKey: " + encryptedKey);
        // step 4 save the key to file
        // ... omitted
        // step 5 load the key from file
        // ... omitted
        // step 6 decrypt the encrypted data to an ecdsa public key
        PrivateKey ecdsaPrivateKeyLoaded = aesGcmDecryptFromBase64(randomKey, encryptedKey);
        System.out.println("ecdsaPrivateKeyLoaded:\n" + bytesToHex(ecdsaPrivateKeyLoaded.getEncoded()));
    }
    public static KeyPair generateEcdsaKeyPair(int keylengthInt)
            throws NoSuchAlgorithmException {
        KeyPairGenerator keypairGenerator = KeyPairGenerator.getInstance("EC");
        SecureRandom random = SecureRandom.getInstance("SHA1PRNG");
        keypairGenerator.initialize(keylengthInt, random);
        return keypairGenerator.generateKeyPair();
    }

    private static byte[] generateRandomAesKey() {
        SecureRandom secureRandom = new SecureRandom();
        byte[] key = new byte[32];
        secureRandom.nextBytes(key);
        return key;
    }

    private static byte[] generateRandomNonce() {
        SecureRandom secureRandom = new SecureRandom();
        byte[] nonce = new byte[12];
        secureRandom.nextBytes(nonce);
        return nonce;
    }

    private static String aesGcmEncryptToBase64(byte[] key, byte[] data)
            throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException,
            InvalidKeyException, BadPaddingException, IllegalBlockSizeException {
        byte[] nonce = generateRandomNonce();
        SecretKeySpec secretKeySpec = new SecretKeySpec(key, "AES");
        GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(16 * 8, nonce);
        Cipher cipher = Cipher.getInstance("AES/GCM/PKCS5Padding");
        cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, gcmParameterSpec);
        byte[] ciphertextWithTag = cipher.doFinal(data);
        byte[] ciphertext = new byte[(ciphertextWithTag.length-16)];
        byte[] gcmTag = new byte[16];
        System.arraycopy(ciphertextWithTag, 0, ciphertext, 0, (ciphertextWithTag.length - 16));
        System.arraycopy(ciphertextWithTag, (ciphertextWithTag.length-16), gcmTag, 0, 16);
        String nonceBase64 = base64Encoding(nonce);
        String ciphertextBase64 = base64Encoding(ciphertext);
        String gcmTagBase64 = base64Encoding(gcmTag);
        return nonceBase64 + ":" + ciphertextBase64 + ":" + gcmTagBase64;
    }

    private static PrivateKey aesGcmDecryptFromBase64(byte[] key, String data)
            throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException,
            InvalidKeyException, BadPaddingException, IllegalBlockSizeException, InvalidKeySpecException {
        String[] parts = data.split(":", 0);
        byte[] nonce = base64Decoding(parts[0]);
        byte[] ciphertextWithoutTag = base64Decoding(parts[1]);
        byte[] gcmTag = base64Decoding(parts[2]);
        byte[] encryptedData = concatenateByteArrays(ciphertextWithoutTag, gcmTag);
        Cipher cipher = Cipher.getInstance("AES/GCM/PKCS5Padding");
        SecretKeySpec secretKeySpec = new SecretKeySpec(key, "AES");
        GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(16 * 8, nonce);
        cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, gcmParameterSpec);
        byte[] encodedEcdsaKey = cipher.doFinal(encryptedData);
        KeyFactory keyFactory = KeyFactory.getInstance("EC");
        PKCS8EncodedKeySpec privateKeySpec = new PKCS8EncodedKeySpec(encodedEcdsaKey);
        return keyFactory.generatePrivate(privateKeySpec);
    }

    private static String base64Encoding(byte[] input) {
        return Base64.getEncoder().encodeToString(input);
    }
    private static byte[] base64Decoding(String input) {
        return Base64.getDecoder().decode(input);
    }

    private static String bytesToHex(byte[] bytes) {
        StringBuffer result = new StringBuffer();
        for (byte b : bytes) result.append(Integer.toString((b & 0xff) + 0x100, 16).substring(1));
        return result.toString();
    }

    public static byte[] concatenateByteArrays(byte[] a, byte[] b) {
        return ByteBuffer
                .allocate(a.length + b.length)
                .put(a).put(b)
                .array();
    }
}
Michael Fehr
  • 5,827
  • 2
  • 19
  • 40
  • Thank you, @Michael Fehr, but I am specifically looking for a way to use an encrypted PEM or DER format. While I can keep this inside Java for the time being, I expect to have a need to externalize it very soon. So I am looking for ways to specifically encrypt the key in a way that I won't have to completely rework if/when that need arises. – Tim Dean Feb 26 '21 at 19:43
  • @Tim Dean: I'm wishing you good luck because up to now I've never seen any write/read solutions for "openssl-encryption" without external libraries. An alternative solution could be the use of a Java key store that provides a password check. – Michael Fehr Feb 26 '21 at 22:56
0

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());
}
Tim Dean
  • 8,253
  • 2
  • 32
  • 59