javabouncycastleopenssh

How to convert an ECDSA public key (ECPublicKey) to OpenSSH format?


How do I convert a java.security.interfaces.ECPublicKey to OpenSSH string representation for use with ssh?

For example, given an ECPublicKey I want to get back a string like this: ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEAFuExXweUtKN3KYzoV+6eEyVfN9CLyM48FO2B9bZQ51bLtQvVo1MNVCXuW73dD2CgHXPryEwsTMyUR74GHN50= user@example.com


Solution

  • Answering my own question...

    You only need the toOpenSshPublicKey() function defined below. However, I've also included some related functions that you might useful:

    Code

    import com.github.cowwoc.pouch.core.WrappedCheckedException;
    import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
    import org.bouncycastle.asn1.x9.ECNamedCurveTable;
    import org.bouncycastle.asn1.x9.X9ECParameters;
    import org.bouncycastle.crypto.params.AsymmetricKeyParameter;
    import org.bouncycastle.crypto.params.ECDomainParameters;
    import org.bouncycastle.crypto.params.ECPublicKeyParameters;
    import org.bouncycastle.crypto.util.OpenSSHPublicKeyUtil;
    import org.bouncycastle.jce.spec.ECNamedCurveSpec;
    import org.bouncycastle.math.ec.ECCurve;
    import org.bouncycastle.math.ec.ECPoint;
    import org.bouncycastle.math.ec.FixedPointCombMultiplier;
    import org.bouncycastle.openssl.PEMKeyPair;
    import org.bouncycastle.openssl.PEMParser;
    import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
    
    import java.io.BufferedReader;
    import java.io.IOException;
    import java.math.BigInteger;
    import java.nio.file.Files;
    import java.nio.file.Path;
    import java.security.KeyFactory;
    import java.security.MessageDigest;
    import java.security.NoSuchAlgorithmException;
    import java.security.NoSuchProviderException;
    import java.security.PrivateKey;
    import java.security.PublicKey;
    import java.security.interfaces.ECPrivateKey;
    import java.security.interfaces.ECPublicKey;
    import java.security.spec.ECParameterSpec;
    import java.security.spec.ECPublicKeySpec;
    import java.security.spec.InvalidKeySpecException;
    import java.util.Base64;
    import java.util.StringJoiner;
    
    import static com.github.cowwoc.requirements10.java.DefaultJavaValidators.requireThat;
    import static org.bouncycastle.jce.provider.BouncyCastleProvider.PROVIDER_NAME;
    
    /**
     * SSH helper functions.
     */
    public class SshKeys
    {
        private final JcaPEMKeyConverter pemToJca = new JcaPEMKeyConverter();
    
        /**
         * Creates a new instance.
         */
        public SshKeys()
        {
        }
    
        /**
         * Loads a {@code PrivateKey} from a file.
         *
         * @param path the path of the private key file
         * @return the {@code PrivateKey}
         * @throws IOException if the file does not contain a valid {@code PrivateKey}
         */
        public PrivateKey loadPrivateKey(Path path) throws IOException
        {
            try (BufferedReader reader = Files.newBufferedReader(path);
                 PEMParser parser = new PEMParser(reader))
            {
                Object keyPair = parser.readObject();
                return switch (keyPair)
                {
                    // Based on https://github.com/bcgit/bc-java/blob/b048c864157376fdaa1a889588ce1dea08629d7a/mail/src/test/java/org/bouncycastle/mail/smime/test/MailGeneralTest.java#L281
                    case PEMKeyPair pem -> pemToJca.getPrivateKey(pem.getPrivateKeyInfo());
                    // Based on https://github.com/bcgit/bc-java/blob/b048c864157376fdaa1a889588ce1dea08629d7a/mail/src/test/java/org/bouncycastle/mail/smime/test/MailGeneralTest.java#L407
                    case PrivateKeyInfo pki -> pemToJca.getPrivateKey(pki);
                    default -> throw new ClassCastException(keyPair.getClass().getName());
                };
            }
        }
    
        /**
         * Converts a private key to a public key, if possible.
         *
         * @param privateKey the private key
         * @return the public key
         */
        public PublicKey convertToPublicKey(PrivateKey privateKey)
        {
            // Get the key factory based on the private key algorithm
            try
            {
                KeyFactory keyFactory = KeyFactory.getInstance(privateKey.getAlgorithm(), PROVIDER_NAME);
                return switch (privateKey)
                {
                    case ECPrivateKey ecPrivateKey ->
                    {
                        // Based on https://github.com/aergoio/heraj/blob/0bcea46c46429c320da711632624605a6225d20f/core/util/src/main/java/hera/util/pki/ECDSAKeyGenerator.java#L173
                        // and https://github.com/bcgit/bc-java/blob/efe1f511d8c58978af38e45215a7b7bf6477a10c/pkix/src/main/java/org/bouncycastle/eac/jcajce/JcaPublicKeyConverter.java#L83
                        ECParameterSpec params = ecPrivateKey.getParams();
    
                        // Find the curve name from the private key parameters
                        String curveName = ((ECNamedCurveSpec) params).getName();
                        X9ECParameters x9Params = ECNamedCurveTable.getByName(curveName);
                        ECDomainParameters domainParams = new ECDomainParameters(
                            x9Params.getCurve(),
                            x9Params.getG(),
                            x9Params.getN(),
                            x9Params.getH(),
                            x9Params.getSeed()
                        );
    
                        BigInteger d = ecPrivateKey.getS();
                        ECPoint Q = new FixedPointCombMultiplier().multiply(domainParams.getG(), d).normalize();
    
                        // Create ECPublicKeySpec
                        ECPublicKeySpec spec = new ECPublicKeySpec(
                            new java.security.spec.ECPoint(Q.getAffineXCoord().toBigInteger(),
                                Q.getAffineYCoord().toBigInteger()), params
                        );
    
                        yield keyFactory.generatePublic(spec);
                    }
                    default -> throw new ClassCastException(privateKey.getClass().getName());
                };
            }
            catch (NoSuchAlgorithmException | NoSuchProviderException | InvalidKeySpecException e)
            {
                throw WrappedCheckedException.wrap(e);
            }
        }
    
        /**
         * Returns the OpenSSH representation of a public key.
         *
         * @param publicKey the public key
         * @param comment   the comment to append to the end of the key
         * @return the OpenSSH representation of the public key
         */
        public String toOpenSshPublicKey(PublicKey publicKey, String comment)
        {
            return switch (publicKey)
            {
                case ECPublicKey ecPublicKey -> toOpenSshPublicKey(ecPublicKey, comment);
                default -> throw new ClassCastException(publicKey.getClass().getName());
            };
        }
    
        /**
         * @param publicKey a public key
         * @return the OpenSSH fingerprint of the key
         * @throws NullPointerException if {@code publicKey} is null
         */
        public String toOpenSshFingerprint(PublicKey publicKey)
        {
            return switch (publicKey)
            {
                case ECPublicKey ecPublicKey -> toOpenSshFingerprint(ecPublicKey);
                default -> throw new ClassCastException(publicKey.getClass().getName());
            };
        }
    
        /**
         * @param publicKey a public key
         * @return the OpenSSH fingerprint of the key
         * @throws NullPointerException if {@code publicKey} is null
         */
        public String toOpenSshFingerprint(ECPublicKey publicKey)
        {
            String opensshPublicKey = toOpenSshPublicKey(publicKey, "");
    
            // Extract the base64 part of the OpenSSH public key
            String base64Key = opensshPublicKey.split(" ")[1];
            byte[] keyBytes = Base64.getDecoder().decode(base64Key);
    
            // Compute the SHA-256 fingerprint
            MessageDigest md;
            try
            {
                md = MessageDigest.getInstance("MD5");
            }
            catch (NoSuchAlgorithmException e)
            {
                // This is a deployment-time decision. Either the JVM supports this digest type or it doesn't.
                throw new AssertionError(e);
            }
            byte[] fingerprint = md.digest(keyBytes);
    
            StringJoiner hexFingerprint = new StringJoiner(":");
            for (byte b : fingerprint)
                hexFingerprint.add(String.format("%02x", b));
            return hexFingerprint.toString();
        }
    
        /**
         * @param publicKey the public key
         * @param comment   the comment to append to the end of the key
         * @return the OpenSSH representation of the key
         * @throws NullPointerException if any of the arguments are null
         */
        public String toOpenSshPublicKey(ECPublicKey publicKey, String comment)
        {
            requireThat(comment, "comment").isNotNull();
            AsymmetricKeyParameter param = getAsymmetricKeyParameter(publicKey);
    
            // Encode the public key in OpenSSH format
            byte[] encodedPublicKey;
            try
            {
                encodedPublicKey = OpenSSHPublicKeyUtil.encodePublicKey(param);
            }
            catch (IOException e)
            {
                // The exception is declared but never actually thrown by the method
                throw new AssertionError(e);
            }
    
            // Determine the SSH key type based on the curve name
            String sshKeyType = getSshKeyType(publicKey);
            return sshKeyType + " " + Base64.getEncoder().encodeToString(encodedPublicKey) + " " + comment;
        }
    
        /**
         * @param publicKey a public key
         * @return the asymmetric key parameters of the key
         * @throws NullPointerException if {@code publicKey} is null
         */
        private AsymmetricKeyParameter getAsymmetricKeyParameter(ECPublicKey publicKey)
        {
            // Retrieve the curve parameters from the named curve
            X9ECParameters ecParams = ECNamedCurveTable.getByName(getCurveName(publicKey));
            ECCurve curve = ecParams.getCurve();
            ECPoint g = ecParams.getG();
            BigInteger n = ecParams.getN();
            BigInteger h = ecParams.getH();
    
            // Convert java.security.spec.ECPoint to BouncyCastle ECPoint
            java.security.spec.ECPoint w = publicKey.getW();
            ECPoint q = curve.createPoint(w.getAffineX(), w.getAffineY());
    
            ECDomainParameters domainParams = new ECDomainParameters(curve, g, n, h);
            return new ECPublicKeyParameters(q, domainParams);
        }
    
        /**
         * @param publicKey a public key
         * @return the name of the elliptic curve used in the public key
         * @throws NullPointerException if {@code publicKey} is null
         */
        private String getCurveName(ECPublicKey publicKey)
        {
            ECNamedCurveSpec params = (ECNamedCurveSpec) publicKey.getParams();
            return params.getName();
        }
    
        /**
         * @param publicKey a public key
         * @return the SSH type of the public key
         * @throws NullPointerException if {@code publicKey} is null
         */
        private String getSshKeyType(ECPublicKey publicKey)
        {
            String curveName = getCurveName(publicKey);
            // SSH key prefix: https://datatracker.ietf.org/doc/html/rfc5656#section-6.2
            return "ecdsa-sha2-" + switch (curveName)
            {
                // Mapping from curve type to SSH key type: https://datatracker.ietf.org/doc/html/rfc5656#section-10.1
                // Equivalent curves: https://www.rfc-editor.org/rfc/rfc4492.html#page-32
                case "secp256r1", "prime256v1" -> "nistp256";
                case "secp384r1" -> "nistp384";
                case "secp521r1" -> "nistp521";
                default -> throw new IllegalArgumentException("Invalid curve type: " + publicKey);
            };
        }
    }