The OpenSSH private key binary format

I have been messing around recently with the binary format used for OpenSSH private keys, and got a bit confused about the format when I had to decode the various parts of it.

As it turned out it was not as straight-forward as I thought it would be. It was not hard either, but definitely not a straight-forward decoding, like I had to do for the OpenSSH public keys for example. RFC 4253, section 6.6 describes the format of OpenSSH public keys and following that RFC it’s quite easy to implement a parser and decode the various bits that comprise an OpenSSH public key.

Unlike OpenSSH public keys, however, there is no RFC document, which describes the binary format of private keys, which are generated by ssh-keygen(1).

I’m writing down these details here, mainly for my own personal reference, but others may find them useful as well, since the format was not well documented, and I had to do some research, plus some reverse engineering in order to get it right.

At the end of this document, you will find a somewhat different representation for the binary format used in OpenSSH private keys, which hopefully will make more sense.

If you look around you will eventually find the PROTOCOL.key document, which describes the format of the private key, but at the same time you might get a bit disappointed about how little details are actually in that document.

PROTOCOL.key defines the following format for private keys.

1. Overall format

The key consists of a header, a list of public keys, and
an encrypted list of matching private keys.

#define AUTH_MAGIC      "openssh-key-v1"

    byte[]  AUTH_MAGIC
    string  ciphername
    string  kdfname
    string  kdfoptions
    int     number of keys N
    string  publickey1
    string  publickey2
    ...
    string  publickeyN
    string  encrypted, padded list of private keys

Then it describes what the KDF options and name are, along with a section describing the list unencrypted list of private key pairs.

2. KDF options for kdfname "bcrypt"

The options:

    string salt
    uint32 rounds

are concatenated and represented as a string.

3. Unencrypted list of N private keys

The list of privatekey/comment pairs is padded with the
bytes 1, 2, 3, ... until the total length is a multiple
of the cipher block size.

    uint32  checkint
    uint32  checkint
    string  privatekey1
    string  comment1
    string  privatekey2
    string  comment2
    ...
    string  privatekeyN
    string  commentN
    char    1
    char    2
    char    3
    ...
    char    padlen % 255

The data types (e.g. string, uint32, etc.) used to describe the various fields from above text are the ones that are defined in RFC 4251, but that is not something that you will see mentioned in PROTOCOL.key document, so it’s good that you know that.

Also, looking at the specification above you might get the impression that within a private key there can be multiple public keys, but that’s not quite true. With the current implementation (as of today) there can be only one public and private key, encoded within a private key blob.

I’ll try to clarify a bit the different fields that are part of the private key blob, so that my future self (and hopefully others) would be able to make sense of it and be able to easily decode it.

The AUTH_MAGIC magic field is a hard-coded, null-terminated string with a value set to “openssh-key-v1”. This is a not a string value, as defined in RFC 4251, so make sure that you read up until the NULL-terminator when parsing it.

The ciphername, kdfname and kdfoptions are usually set when the private key is protected with a passphrase. When no passphrase is used to protect the private key they default to are these.

string  ciphername => "none"
string  kdfname    => "none"
string  kdfoptions => "" (empty string)

The kdfname field specifies a KDF name, which can be either “bcrypt” or “none”. Anything else will be refused.

The ciphername field should be a valid cipher name as found in cipher.c. In cipher.c you will also find the blocksize, which is needed when you need to pad the data.

struct sshcipher_ctx {
    int plaintext;
    int encrypt;
    EVP_CIPHER_CTX *evp;
    struct chachapoly_ctx *cp_ctx;
    struct aesctr_ctx ac_ctx; /* XXX union with evp? */
    const struct sshcipher *cipher;
};

struct sshcipher {
    char    *name;
    u_int   block_size;
    u_int   key_len;
    u_int   iv_len;     /* defaults to block_size */
    u_int   auth_len;
    u_int   flags;
#define CFLAG_CBC       (1<<0)
#define CFLAG_CHACHAPOLY    (1<<1)
#define CFLAG_AESCTR        (1<<2)
#define CFLAG_NONE      (1<<3)
#define CFLAG_INTERNAL      CFLAG_NONE /* Don't use "none" for packets */
#ifdef WITH_OPENSSL
    const EVP_CIPHER    *(*evptype)(void);
#else
    void    *ignored;
#endif
};

static const struct sshcipher ciphers[] = {
#ifdef WITH_OPENSSL
#ifndef OPENSSL_NO_DES
    { "3des-cbc",       8, 24, 0, 0, CFLAG_CBC, EVP_des_ede3_cbc },
#endif
    { "aes128-cbc",     16, 16, 0, 0, CFLAG_CBC, EVP_aes_128_cbc },
    { "aes192-cbc",     16, 24, 0, 0, CFLAG_CBC, EVP_aes_192_cbc },
    { "aes256-cbc",     16, 32, 0, 0, CFLAG_CBC, EVP_aes_256_cbc },
    { "rijndael-cbc@lysator.liu.se",
                16, 32, 0, 0, CFLAG_CBC, EVP_aes_256_cbc },
    { "aes128-ctr",     16, 16, 0, 0, 0, EVP_aes_128_ctr },
    { "aes192-ctr",     16, 24, 0, 0, 0, EVP_aes_192_ctr },
    { "aes256-ctr",     16, 32, 0, 0, 0, EVP_aes_256_ctr },
# ifdef OPENSSL_HAVE_EVPGCM
    { "aes128-gcm@openssh.com",
                16, 16, 12, 16, 0, EVP_aes_128_gcm },
    { "aes256-gcm@openssh.com",
                16, 32, 12, 16, 0, EVP_aes_256_gcm },
# endif /* OPENSSL_HAVE_EVPGCM */
#else
    { "aes128-ctr",     16, 16, 0, 0, CFLAG_AESCTR, NULL },
    { "aes192-ctr",     16, 24, 0, 0, CFLAG_AESCTR, NULL },
    { "aes256-ctr",     16, 32, 0, 0, CFLAG_AESCTR, NULL },
#endif
    { "chacha20-poly1305@openssh.com",
                8, 64, 0, 16, CFLAG_CHACHAPOLY, NULL },
    { "none",       8, 0, 0, 0, CFLAG_NONE, NULL },

    { NULL,         0, 0, 0, 0, 0, NULL }
};

If encryption has been used the kdfoptions field will be a buffer, preceeded with it’s length, which embeds the salt and number of rounds. It should not be directly interpreted as a string value, as it’s type suggests, but rather as a bytes buffer, which embeds some data inside of it.

Next is the number-of-keys field. This field is described in PROTOCOL.key as an int, but in reality it is an uint32 value. It’s value will always be 1, at least in the current version of the format.

The publickey part of the blob should be interpreted in a similar way that kdfoptions are being parsed – first we need to parse the size of the buffer by reading an uint32 value, and the value we get determines the bytes we need to read next, which make up the public key part. The public key then is encoded following the format defined in RFC 4253, with data types defined in RFC 4251.

After the publickey we have the encrypted section, which is again a buffer, which size is determined by an uint32 value preceeding the actual contents. When no passphrase is being used the contents of this section will not be encrypted, otherwise you will need to first decrypt the bytes using the correct passphrase and then you can proceed.

The encrypted section starts with two uint32 values, which are called checkint. These values should be the same once you successfully decrypt this section. That’s an easy way to verify whether a given passphrase is the correct one.

And right after the checkint values we have the privatekey parts. This one start with a string value, which specifies the key type (e.g. “ssh-rsa”), followed by the actual private key components.

After the private key blob we have a string value, which specifies the comment associated with the key.

And finally we have a padding section. The padding size is determined by the ciphername, e.g. the “none” cipher uses blocksize of 8, “aes256-ctr” cipher uses a blocksize of 16, etc. Please refer to cipher.c for more details about the different ciphers and their blocksize.

This one is important when encoding a private key, as the size of the encrypted section should be length(encrypted) % cipher_blocksize() == 0. This is how this section is padded at the end.

    /* pad to cipher blocksize */
    i = 0;
    while (sshbuf_len(prvbuf) % cipher_blocksize(cipher)) {
        if ((r = sshbuf_put_u8(prvbuf, ++i & 0xff)) != 0)
            goto out;
    }

For example lets say that you have an encrypted section blob, which size after encoding all the relevant bits is 1630. The cipher that we have used is “none”, which tells us to use a blocksize of 8. Following above code we should have 2 bytes used for padding – the bytes 0x01 and 0x02, because the final size would meet the requirement of 1632 % 8 == 0.

Finally, we can create the following expanded representation of the private key blob. In the example below I’m using an RSA public and private key pair, just to illustrate the different fields. Other kinds of public/private key pairs will contain a different set of fields.

;; AUTH_MAGIC is a hard-coded, null-terminated string,
;; set to "openssh-key-v1".
byte[n] AUTH_MAGIC

;; ciphername determines the cipher name (if any),
;; or is set to "none", when no encryption is used.
string   ciphername

;; kdfname determines the KDF function name, which is
;; either "bcrypt" or "none"
string   kdfname

;; kdfoptions field.
;; This one is actually a buffer with size determined by the
;; uint32 value, which preceeds it.
;; If no encryption was used to protect the private key,
;; it's contents will be the [0x00 0x00 0x00 0x00] bytes (empty string).
;; You should read the embedded buffer, only if it's size is
;; different than 0.
uint32 (size of buffer)
    string salt
    uint32 rounds

;; Number of keys embedded within the blob.
;; This value is always set to 1, at least in the
;; current implementation of the private key format.
uint32 number-of-keys

;; Public key section.
;; This one is a buffer, in which the public key is embedded.
;; Size of the buffer is determined by the uint32 value,
;; which preceeds it.
;; The public components below are for RSA public keys.
uint32 (size of buffer)
    string keytype ("ssh-rsa")
    mpint  e       (RSA public exponent)
    mpint  n       (RSA modulus)

;; Encrypted section
;; This one is a again a buffer with size
;; specified by the uint32 value, which preceeds it.
;; The fields below are for RSA private keys.
uint32 (size of buffer)
    uint32  check-int
    uint32  check-int  (must match with previous check-int value)
    string  keytype    ("ssh-rsa")
    mpint   n          (RSA modulus)
    mpint   e          (RSA public exponent)
    mpint   d          (RSA private exponent)
    mpint   iqmp       (RSA Inverse of Q Mod P, a.k.a iqmp)
    mpint   p          (RSA prime 1)
    mpint   q          (RSA prime 2)
    string  comment    (Comment associated with the key)
    byte[n] padding    (Padding according to the rules above)

The RSA components used in above example are the ones defined in RFC 8017.

Keep in mind that different public/private key pairs will have a different set of fields, but they will all follow the above structure when being encoded.

This is what the binary representation for a DSA private key looks like.

;; AUTH_MAGIC is a hard-coded, null-terminated string,
;; set to "openssh-key-v1".
byte[n] AUTH_MAGIC

;; ciphername determines the cipher name (if any),
;; or is set to "none", when no encryption is used.
string   ciphername

;; kdfname determines the KDF function name, which is
;; either "bcrypt" or "none"
string   kdfname

;; kdfoptions field.
;; This one is actually a buffer with size determined by the
;; uint32 value, which preceeds it.
;; If no encryption was used to protect the private key,
;; it's contents will be the [0x00 0x00 0x00 0x00] bytes (empty string).
;; You should read the embedded buffer, only if it's size is
;; different than 0.
uint32 (size of buffer)
    string salt
    uint32 rounds

;; Number of keys embedded within the blob.
;; This value is always set to 1, at least in the
;; current implementation of the private key format.
uint32 number-of-keys

;; Public key section.
;; This one is a buffer, in which the public key is embedded.
;; Size of the buffer is determined by the uint32 value,
;; which preceeds it.
;; DSA parameters embedded in the buffer as defined in FIPS-186-2, section 4.
uint32 (size of buffer)
    string keytype ("ssh-dss")
    mpint  p
    mpint  q
    mpint  g
    mpint  y

;; Encrypted section
;; This one is a again a buffer with size
;; specified by the uint32 value, which preceeds it.
;; DSA key format.
uint32 (size of buffer)
    uint32  check-int
    uint32  check-int  (must match with previous check-int value)
    string  keytype    ("ssh-dss")
    mpint   p          (DSA parameters defined in FIPS-186-2, section 4)
    mpint   q
    mpint   g
    mpint   y          (public key)
    mpint   x          (private key)
    string  comment    (Comment associated with the key)
    byte[n] padding    (Padding according to the rules above)

The following is the representation for ED25519 private keys.

;; AUTH_MAGIC is a hard-coded, null-terminated string,
;; set to "openssh-key-v1".
byte[n] AUTH_MAGIC

;; ciphername determines the cipher name (if any),
;; or is set to "none", when no encryption is used.
string   ciphername

;; kdfname determines the KDF function name, which is
;; either "bcrypt" or "none"
string   kdfname

;; kdfoptions field.
;; This one is actually a buffer with size determined by the
;; uint32 value, which preceeds it.
;; If no encryption was used to protect the private key,
;; it's contents will be the [0x00 0x00 0x00 0x00] bytes (empty string).
;; You should read the embedded buffer, only if it's size is
;; different than 0.
uint32 (size of buffer)
    string salt
    uint32 rounds

;; Number of keys embedded within the blob.
;; This value is always set to 1, at least in the
;; current implementation of the private key format.
uint32 number-of-keys

;; Public key section.
;; This one is a buffer, in which the public key is embedded.
;; Size of the buffer is determined by the uint32 value,
;; which preceeds it.
;; ED25519 public key components.
uint32 (size of buffer)
    string keytype ("ssh-ed25519")

    ;; The ED25519 public key is a buffer of size 32.
    ;; The encoding follows the same rules for any
    ;; other buffer used by SSH -- the size of the
    ;; buffer preceeds the actual data.
    uint32 + byte[32]

;; Encrypted section
;; This one is a again a buffer with size
;; specified by the uint32 value, which preceeds it.
;; ED25519 private key.
uint32 (size of buffer)
    uint32  check-int
    uint32  check-int  (must match with previous check-int value)
    string  keytype    ("ssh-ed25519")

    ;; The public key
    uint32 + byte[32]  (public key)

    ;; Secret buffer. This is a buffer with size 64 bytes.
    ;; The bytes[0..32] contain the private key and
    ;; bytes[32..64] contain the public key.
    ;; Once decoded you can extract the private key by
    ;; taking the byte[0..32] slice.
    uint32 + byte[64]  (secret buffer)

    string  comment    (Comment associated with the key)
    byte[n] padding    (Padding according to the rules above)

You can find out more about the different kinds of keys and the fields they have in RFC 4253 and PROTOCOL.certkeys documents.

The following references are also useful, so make sure to check these as well.

Written on August 5, 2020