Search code examples
javabouncycastleapache-minaed25519eddsa

Encoding a Ed25519 Public Key to SSH format in Java


To begin with I am new to cryptography and the different types of keys/encodings/formats, so correct me if I am wrong somewhere. I have a java application that needs to save a Ed25519 key to a keystore. The application is a legacy and some of the methods and libraries cannot be changed. It is using Apache MINA SSHD and BouncyCastle for storing keys in the keystore and also encoding the public keys to SSH format. There isnt a problem with RSA and DSA keys. The problem is that Apache MINA uses a net.i2p implementation of EdDSA keys and BouncyCastle uses its own BCEdDSA keys. A method that we use comes from MINA and returns net.i2p key for witch I had trouble saving it to the keystore, because BouncyCastle's JCAContentSigner couldn't recognize this implementation of the key. The problem comes when encoding the public keys.

I created a class that implements ContentSigner and uses net.i2p's EdDSAEngine class to create a signer.

private class EdDSAContentSigner implements ContentSigner
    {
        private final AlgorithmIdentifier sigAlgId;
        private final PrivateKey privateKey;
        private ByteArrayOutputStream stream;

        public EdDSAContentSigner(AlgorithmIdentifier sigAlgId, PrivateKey privKey)
        {
            this.sigAlgId = sigAlgId;
            this.privateKey = privKey;
            this.stream = new ByteArrayOutputStream();
        }


        @Override
        public AlgorithmIdentifier getAlgorithmIdentifier()
        {
            return sigAlgId;
        }


        @Override
        public OutputStream getOutputStream()
        {
            stream.reset();
            return stream;
        }


        @Override
        public byte[] getSignature()
        {
            byte[] dataToSign = stream.toByteArray();
            try
            {
                EdDSAEngine sig = new EdDSAEngine();
                sig.initSign(privateKey);
                return sig.signOneShot(dataToSign);
            }
            catch (GeneralSecurityException e)
            {
                LOG.error("Cannot sign data : " + e.getMessage(), e);
                throw new IllegalStateException("Cannot sign data : " + e.getMessage(), e);
            }
        }
    }

then I managed to create a certificate and save the key to the keystore. The application has the functionality to upload a private key file it generates the public key in SSH format - starting with ssh-ed25519....

Now I have a problem encoding the public key to SSH format. It always differs from the one generated from the ssh-keygen tool even if I use the same private key file. Again the public key could be of different types net.i2p/BouncyCastle/sun.security.ec.ed.EdDSAPublicKeyImpl depending on what method calls my encodeEdDSAPublicKey method. I tried different ways to encode it from calling the .getEncoded() method on the PublicKey itself to using BouncyCastle's ASN1InputStream which is my latest method. I will also provide the RSA key encoding method witch returns the same encoding as the ssh-keygen tool. I want to be able to use the public keys for connecting to a server using putty. Any help/suggestions will be appreciated.

    private static String encodeEdDSAPublicKey(PublicKey publicKey)
        throws IOException
    {
       try(ASN1InputStream asn1InputStream = new ASN1InputStream(publicKey.getEncoded()))
        {
            ASN1Primitive primitive = asn1InputStream.readObject();
            byte[] keyBytes ((ASN1Sequence)primitive).getObjectAt(1).toASN1Primitive().getEncoded();
          ByteArrayOutputStream byteOs = new ByteArrayOutputStream();
          DataOutputStream dos = new DataOutputStream(byteOs);
          dos.writeInt("ssh-ed25519".getBytes().length);
          dos.write("ssh-ed25519".getBytes());
          dos.writeInt(keyBytes.length);
          dos.write(keyBytes);
          return Base64.getEncoder().encodeToString(byteOs.toByteArray());
        }
    }
    private static String encodeRSAPublicKey(PublicKey publicKey)
        throws IOException
    {
        String publicKeyEncoded;
        RSAPublicKey rsaPublicKey = (RSAPublicKey)publicKey;
        ByteArrayOutputStream byteOs = new ByteArrayOutputStream();
        DataOutputStream dos = new DataOutputStream(byteOs);
        dos.writeInt("ssh-rsa".getBytes().length);
        dos.write("ssh-rsa".getBytes());
        dos.writeInt(rsaPublicKey.getPublicExponent().toByteArray().length);
        dos.write(rsaPublicKey.getPublicExponent().toByteArray());
        dos.writeInt(rsaPublicKey.getModulus().toByteArray().length);
        dos.write(rsaPublicKey.getModulus().toByteArray());
        publicKeyEncoded = Base64.getEncoder().encodeToString(byteOs.toByteArray());
        return publicKeyEncoded;
    }

and this is the method in which I choose the correct encoding method based on algorithm

    public static String encodePublicKey(PublicKey publicKey, String name)
        throws IOException
    {
        String suffix = "";
        String algorithm = publicKey.getAlgorithm();

        if (name != null)
        {
            suffix = name);
        }

        switch (algorithm)
        {
        case "RSA":
            return "ssh-rsa " + encodeRSAPublicKey(publicKey) + suffix;
        case "DSA":
            return "ssh-dss " + encodeDSAPublicKey(publicKey) + suffix;
        case "Ed25519":
        case "EdDSA":
            return "ssh-ed25519 " + encodeEdDSAPublicKey(publicKey) + suffix;
        default:
            throw new IOException("Unknown public key encoding: " + publicKey.getAlgorithm());
        }
    }

Solution

  • You were very close. Java PublicKey.getEncoded() is a SPKI structure whose field 1 is a BIT STRING containing the algorithm-specific data which for EdDSA is the raw point encoding. Use:

        ASN1Object spki = new ASN1InputStream(pubkey.getEncoded()) .readObject();
        // or wrapped in the try as you have it is slightly cleaner
        byte[] point = ((ASN1BitString) ((ASN1Sequence)spki).getObjectAt(1) ).getOctets();
    

    or use the type-specific Bouncy classes to handle the parsing and typecasting for you:

        byte[] point = SubjectPublicKeyInfo.getInstance(pubkey.getEncoded()).getPublicKeyData().getOctets();
    

    Alternatively IF using the Bouncy provider (and key class) more simply just use:

        byte[] point = ((org.bouncycastle.jcajce.interfaces.EdDSAPublicKey)pubkey).getPointEncoding();
        // or org.bouncycastle.jcajce.provider.asymmetric.edec.BCEdDSAPublicKey 
    

    And in any case write length&contents of point into the SSH format as you already have.