LoginSignup
2
1

More than 3 years have passed since last update.

[memo] SSH用RSA鍵ペアをJavaで生成する

Last updated at Posted at 2019-05-04

はじめに

ちなみに、普通に ssh-keygen を使うなら以下のような感じ。

ssh-keygen -t rsa -b 4096

RSA の復習 「RSA鍵ペアの概要」

以下の Qiita 記事から説明をお借りします。

(ここから借用)

大きな2つの素数を$p$,$q$とした場合、

N = pq \tag{1}

以下(2)を満たす変数を$L$とする。

L = lcm(p-1, q-1) \tag{2}

さらに、次の式(3)を満たす変数を$E$とする。

gcd(E, L) = 1 \tag{3}

最後に、式(4)を満たす変数を$D$とする。

ED≡1 (mod L) \tag{4}

※ lcm(x,y)はx,yの最小公倍数(Least Common Multiple)
※ gcd(x,y)はx,yの最大公約数(Greatest Common Divisor)

ここまでで求めた$(E, N)$のペアが公開鍵, $(D, N)$のペアが秘密鍵になります。

(ここまで借用)

OpenSSH の 秘密鍵ファイルフォーマット

最初に書いたように、PEM フォーマットかつパスフレーズ無しを想定しています。

基本的には、PKCS#8 を PEM フォーマットにしたもののようです。

詳細は、前述の「RSA公開鍵のファイル形式とfingerprint」の「PKCS#8形式」が詳しいです。

ちなみに、Java の RSAPrivateKey インスタンスの場合、getEncoded() メソッドでバイナリデータが入手できるので、MIME 風の Base64 エンコードをすればよいはずです。

    public Optional<String> encodePrivateKey(final PrivateKey privateKey) {

        final String keyType;
        if (privateKey instanceof RSAPrivateKey) {
            keyType = "RSA";
        } else if (privateKey instanceof DSAPrivateKey) {
            keyType = "DSA";
        } else if (privateKey instanceof ECPrivateKey) {
            keyType = "EC";
        } else {
            return Optional.empty();
        }

        final byte[] privateBytes = privateKey.getEncoded();
        final String privateBase64 = Base64.getMimeEncoder()
                .encodeToString(privateBytes);

        final String encodedPrivateKey = "-----BEGIN " + keyType + " PRIVATE KEY-----\n"
                + privateBase64 + "\n"
                + "-----END " + keyType + " PRIVATE KEY-----";
        return Optional.of(encodedPrivateKey);
    }

OpenSSH の RSA 公開鍵ファイルフォーマット

RFC4716 に記載の書式とは異なり、OpenSSH 形式の場合には前後に "BEGIN" / "END" のデリミタはない代わりに、先頭に "ssh-rsa" というシグネチャが付きます。

その後のデータは、RFC4716 の形式と同じようです(前述の「RSA公開鍵のファイル形式とfingerprint」の記事より)。

この形式の詳細は RFC4253 の「6.6. Public Key Algorithms」に記載があります

string "ssh-rsa"
mpint e
mpint n

この「string」や「mpint」の形式は RFC4251 の「5. Data Type Representations Used in the SSH Protocols」に記載があります。
簡単に言えば、4バイトの big endian の長さ+バイト列みたいな感じですね。

これを、Base64 でエンコードすればよいみたい。OpenSSH 形式の場合には Base64 は行を折り返さず1行で出力すればよいようです。

$e$ や $n$ は前述の「RSA鍵ペアの概要」の中の説明の $E$ と $N$ にあたります。

ちなみに $e$ と $n$ の値は、 Java の RSAPublicKey インスタンスの場合、それぞれ getPublicExponent() メソッドと getModulus() メソッドで取得できます。

(公開鍵の最後にメールアドレス風なものがついていることがありますが、これはコメントのはずです)

※ちなみに "ssh-rsa" というシグネチャは、base64 されているデータの中にも含まれているし(それが上記の string "ssh-rsa" の部分)、それとは別に base64 の前にも付きます(冒頭で言った「デリミタはない代わりに、先頭に "ssh-rsa" というシグネチャが付きます」の部分)。以下のソースを見てもらったほうが分かりやすいと思いますが。

    public String encodeRsaPublicKey(final RSAPublicKey publicKey) {

        final String sig = "ssh-rsa";
        final byte[] sigBytes = sig.getBytes(StandardCharsets.US_ASCII);
        final byte[] eBytes = publicKey.getPublicExponent().toByteArray();
        final byte[] nBytes = publicKey.getModulus().toByteArray();

        final int size = 4 + sigBytes.length
                + 4 + eBytes.length
                + 4 + nBytes.length;

        final byte[] publicKeyBytes = ByteBuffer.allocate(size)
                .order(ByteOrder.BIG_ENDIAN)
                .putInt(sigBytes.length).put(sigBytes)
                .putInt(eBytes.length).put(eBytes)
                .putInt(nBytes.length).put(nBytes)
                .array();

        final String publicKeyBase64 = Base64.getEncoder()
                .encodeToString(publicKeyBytes);

        final String publicKeyEncoded = sig + " " + publicKeyBase64;
        return publicKeyEncoded;
    }

OpenSSH の DSA 公開鍵ファイルフォーマット(おまけ)

DSA は RSA と同様に RFC4253 に記載あり。

string "ssh-dss"
mpint p
mpint q
mpint g
mpint y

    public String encodeDsaPublicKey(final DSAPublicKey publicKey) {

        final String sig = "ssh-dss";
        final byte[] sigBytes = sig.getBytes(StandardCharsets.US_ASCII);
        final DSAParams params = publicKey.getParams();
        final byte[] pBytes = params.getP().toByteArray();
        final byte[] qBytes = params.getQ().toByteArray();
        final byte[] gBytes = params.getG().toByteArray();
        final byte[] yBytes = publicKey.getY().toByteArray();

        final int size = 4 + sigBytes.length
                + 4 + pBytes.length
                + 4 + qBytes.length
                + 4 + gBytes.length
                + 4 + yBytes.length;

        final byte[] publicKeyBytes = ByteBuffer.allocate(size)
                .order(ByteOrder.BIG_ENDIAN)
                .putInt(sigBytes.length).put(sigBytes)
                .putInt(pBytes.length).put(pBytes)
                .putInt(qBytes.length).put(qBytes)
                .putInt(gBytes.length).put(gBytes)
                .putInt(yBytes.length).put(yBytes)
                .array();

        final String publicKeyBase64 = Base64.getEncoder()
                .encodeToString(publicKeyBytes);

        final String publicKeyEncoded = sig + " " + publicKeyBase64;
        return publicKeyEncoded;
    }

OpenSSH の ECDSA (楕円曲線暗号)公開鍵ファイルフォーマット(おまけ)

楕円曲線暗号の公開鍵のフォーマットは RFC5656 で定義しているみたい。

string   "ecdsa-sha2-[identifier]"
byte[n]  ecc_key_blob

ecc_key_blob の中身は

string   [identifier]
string   Q

identifier は、楕円曲線の種類から求められるようです。
以下の表の NIST の部分が identifier になります。

NIST SEC OID
nistp256 secp256r1 1.2.840.10045.3.1.7
nistp384 secp384r1 1.3.132.0.34
nistp521 secp521r1 1.3.132.0.35

ちなみに、この3種類は【必須】の曲線のようです(SSH のサーバ・クライアントでECDSAをサポートする場合には、必ず実装する楕円曲線)。

他に【推奨】の楕円曲線もあるみたいですが、数が多いので割愛します。
ちなみに、推奨の曲線の identifier は NIST の列ではなく OID の列を使うとのこと(例: ecdsa-sha2-1.3.132.0.38 みたいなシグネチャになるはず)。詳細は RFC 参照。

また、Q は「楕円曲線上の点をオクテット文字列にエンコードしたもの」だそうですが、仕様の詳細は http://www.secg.org/download/aid-780/sec1-v2.pdf に記載されているとあります。
実際に探してみたら https://www.secg.org/sec1-v2.pdf に移動しているみたいですが。
このドキュメントの 2.3.3 に詳細な記載があります。
「ポイント圧縮」という機能を使うか、使わないかでフォーマットが異なるようですが、使わない場合には、比較的単純な構造のようです。

とりあえず identifier が決まった前提だと以下のような感じ。

    public String encodeEcPublicKey(
            final String identifier,
            final ECPublicKey publicKey) {

        final String sig = "ecdsa-sha2-" + identifier;
        final BigInteger x = publicKey.getW().getAffineX();
        final BigInteger y = publicKey.getW().getAffineY();

        final byte[] sigBytes = sig.getBytes(StandardCharsets.US_ASCII);
        final byte[] identifierBytes = identifier.getBytes(StandardCharsets.US_ASCII);
        final byte[] xBytes = x.toByteArray();
        final byte[] yBytes = y.toByteArray();

        final int size = 4 + sigBytes.length
                + 4 + identifierBytes.length
                + 4 + 1 + xBytes.length + yBytes.length;

        final byte[] publicKeyBytes = ByteBuffer.allocate(size)
                .order(ByteOrder.BIG_ENDIAN)
                .putInt(sigBytes.length).put(sigBytes)
                .putInt(identifierBytes.length).put(identifierBytes)
                .putInt(1 + xBytes.length + yBytes.length)
                .put((byte) 0x04)
                .put(xBytes)
                .put(yBytes)
                .array();

        final String publicKeyBase64 = Base64.getEncoder()
                .encodeToString(publicKeyBytes);

        final String publicKeyEncoded = sig + " " + publicKeyBase64;
        return publicKeyEncoded;

    }

そして identifier は楕円曲線の種類を示すもので、秘密鍵や公開鍵から取得できる ECParameterSpec の中身で判断がつくようです。

具体的なパラメタは、楕円曲線の仕様書を見る必要があるみたい。

仕様の詳細は http://www.secg.org/download/aid-386/sec2_final.pdf に記載されているとあります。
実際に探してみたらこれも移動していて https://www.secg.org/SEC2-Ver-1.0.pdf にありました。

とりあえず ECParameterSpec をもとに必須の曲線の identifier を決定するには以下のような感じ。

    private Optional<String> decideEcIdentifier(final ECParameterSpec params) {

        if (!(params.getCurve().getField() instanceof ECFieldFp)) {
            return Optional.empty();
        }
        final ECFieldFp field = (ECFieldFp) params.getCurve().getField();
        final BigInteger p = field.getP();
        final BigInteger a = params.getCurve().getA();
        final BigInteger b = params.getCurve().getB();
        final BigInteger g1 = params.getGenerator().getAffineX();
        final BigInteger g2 = params.getGenerator().getAffineY();
        final BigInteger n = params.getOrder();
        final int h = params.getCofactor();

        final List<Number> tuple = Arrays.asList(p, a, b, g1, g2, n, h);
        final String identifier = identifierMap.get(tuple);
        return Optional.ofNullable(identifier);
    }

    private static final Map<List<Number>, String> identifierMap;
    static {
        final Map<List<Number>, String> map = new LinkedHashMap<>();

        map.put(Arrays.asList(
                new BigInteger("115792089210356248762697446949407573530086143415290314195533631308867097853951"),
                new BigInteger("115792089210356248762697446949407573530086143415290314195533631308867097853948"),
                new BigInteger("41058363725152142129326129780047268409114441015993725554835256314039467401291"),
                new BigInteger("48439561293906451759052585252797914202762949526041747995844080717082404635286"),
                new BigInteger("36134250956749795798585127919587881956611106672985015071877198253568414405109"),
                new BigInteger("115792089210356248762697446949407573529996955224135760342422259061068512044369"),
                1), "nistp256");
        map.put(Arrays.asList(
                new BigInteger(
                        "39402006196394479212279040100143613805079739270465446667948293404245721771496870329047266088258938001861606973112319"),
                new BigInteger(
                        "39402006196394479212279040100143613805079739270465446667948293404245721771496870329047266088258938001861606973112316"),
                new BigInteger(
                        "27580193559959705877849011840389048093056905856361568521428707301988689241309860865136260764883745107765439761230575"),
                new BigInteger(
                        "26247035095799689268623156744566981891852923491109213387815615900925518854738050089022388053975719786650872476732087"),
                new BigInteger(
                        "8325710961489029985546751289520108179287853048861315594709205902480503199884419224438643760392947333078086511627871"),
                new BigInteger(
                        "39402006196394479212279040100143613805079739270465446667946905279627659399113263569398956308152294913554433653942643"),
                1), "nistp384");
        map.put(Arrays.asList(
                new BigInteger(
                        "6864797660130609714981900799081393217269435300143305409394463459185543183397656052122559640661454554977296311391480858037121987999716643812574028291115057151"),
                new BigInteger(
                        "6864797660130609714981900799081393217269435300143305409394463459185543183397656052122559640661454554977296311391480858037121987999716643812574028291115057148"),
                new BigInteger(
                        "1093849038073734274511112390766805569936207598951683748994586394495953116150735016013708737573759623248592132296706313309438452531591012912142327488478985984"),
                new BigInteger(
                        "2661740802050217063228768716723360960729859168756973147706671368418802944996427808491545080627771902352094241225065558662157113545570916814161637315895999846"),
                new BigInteger(
                        "3757180025770020463545507224491183603594455134769762486694567779615544477440556316691234405012945539562144444537289428522585666729196580810124344277578376784"),
                new BigInteger(
                        "6864797660130609714981900799081393217269435300143305409394463459185543183397655394245057746333217197532963996371363321113864768612440380340372808892707005449"),
                1), "nistp521");
        identifierMap = Collections.unmodifiableMap(map);
    }

OpenSSH の 公開鍵ファイルフォーマット

上記3種類のフォーマットをエンコードする例です。

    public Optional<String> encodePublicKey(final PublicKey publicKey) {

        if (publicKey instanceof RSAPublicKey) {
            final RSAPublicKey rsaPublicKey = (RSAPublicKey) publicKey;
            return Optional.of(encodeRsaPublicKey(rsaPublicKey));

        } else if (publicKey instanceof DSAPublicKey) {
            final DSAPublicKey dsaPublicKey = (DSAPublicKey) publicKey;
            return Optional.of(encodeDsaPublicKey(dsaPublicKey));

        } else if (publicKey instanceof ECPublicKey) {
            final ECPublicKey ecPublicKey = (ECPublicKey) publicKey;
            return decideEcIdentifier(ecPublicKey.getParams())
                    .map(identifier -> encodeEcPublicKey(identifier, ecPublicKey));
        }
        return Optional.empty();
    }

おまけで RSA 秘密鍵から公開鍵を生成

ssh-keygen を使うなら以下のような感じですが

ssh-keygen -yf id_rsa > id_rsa.pub

Java でやるならこんな感じ。

    KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
    generator.initialize(4096);
    KeyPair keyPair = generator.genKeyPair();
    RSAPrivateCrtKey privateKey = (RSAPrivateCrtKey) keyPair.getPrivate();
    BigInteger publicExponent = privateKey.getPublicExponent();
    BigInteger modulus = privateKey.getModulus();
    KeyFactory factory = KeyFactory.getInstance("RSA");
    RSAPublicKeySpec spec = new RSAPublicKeySpec(
            modulus,
            publicExponent);
    RSAPublicKey publicKey = (RSAPublicKey) factory.generatePublic(spec);

ポイントは、普通に生成した RSA の秘密鍵は RSAPrivateCrtKey も実装している点。

さらにおまけ

ED25519 は RFC はまだ発行されていないのかな?

インターネットドラフトの https://tools.ietf.org/html/draft-ietf-curdle-ssh-ed25519-ed448-08 を見ると以下のような感じ。

string "ssh-ed25519"
string key

同様に ED448 は以下のような感じ。

string "ssh-ed448"
string key

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1