Search code examples
javaencryptionpemecdsader

How can I write an encrypted ECDSA private key file from Java


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.


Solution

  • 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());
    }