# OpenSSH key file format There's no formal spec for an SSH private key file, but the internet has worked it out by scanning the OpenSSH source code (and apparently some trial and error). Between the "start" and "end" lines is base-64 data in a custom format. This format encodes numbers as 32-bit big-endian ("u32be") and strings as UTF-8. Most fields are arbitrary-length, preceded by a u32be length field.mAs shorthand, I'll write `#(item)` to mean `u32be len, u8[len] item`. ## Public key ``` ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBEpBd98eccmtiUOxuh6qjXZEn4vSHNqZcc1Lodo13hl robey@togusa ``` Each key is on one line. The second field is the base-64 encoded key: - #(str name) -- "ssh-ed25519" - #(u8[] pubkey_data) -- always 32 bytes For the sample key, the public key data is: ``` 112905df7c79c726b6250ec6e87aaa35d9127e2f48736a65c7352e8768d77865 ``` ## Private key ``` -----BEGIN OPENSSH PRIVATE KEY----- b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW QyNTUxOQAAACARKQXffHnHJrYlDsboeqo12RJ+L0hzamXHNS6HaNd4ZQAAAJAmpDUdJqQ1 HQAAAAtzc2gtZWQyNTUxOQAAACARKQXffHnHJrYlDsboeqo12RJ+L0hzamXHNS6HaNd4ZQ AAAEC8KYd/oos+A+prWNNYGOZaH7SHDa41xKRS3ogCBRCO+xEpBd98eccmtiUOxuh6qjXZ En4vSHNqZcc1Lodo13hlAAAADHJvYmV5QHRvZ3VzYQE= -----END OPENSSH PRIVATE KEY----- ``` The contents are base64 encoded, with a header and footer similar to an old GPG "armored" file. - header: "openssh-key-v1" 0x00 - #(str cipher_name) -- must be "none" or "aes256-ctr" - #(str kdf_name) -- must be "none" or "bcrypt" - #(str kdf_options) - #(u8[] salt) - rounds: u32be - public_key_count: u32be - #(public_key) -- public_key_count times, format above - #(secret_key_possibly_encrypted): - salt: u8[8] -- unused - #(str algorithm_name) -- "ssh-ed25519" - #(u8[32] public_key) -- again - #(secret_key_data): - u8[32] secret_key - u8[32] public_key -- yep, again - #(str human_key_name) - u8[*] padding This sample key is not encrypted (cipher_name = "none", kdf_name = "none"), and there is one public key, encoded exactly the same as the example above: ``` >>> struct.unpack(">I", data[39:43]) (51,) >>> data[43 : 43 + 51].hex() '0000000b7373682d6564323535313900000020112905df7c79c726b6250ec6e87aaa35d9127e2f48736a65c7352e8768d77865' >>> base64.b64encode(data[43 : 43 + 51]) b'AAAAC3NzaC1lZDI1NTE5AAAAIBEpBd98eccmtiUOxuh6qjXZEn4vSHNqZcc1Lodo13hl' ``` The (unencrypted) secret key contains the raw public key again: ``` >>> struct.unpack(">I", data[121:125]) (32,) >>> data[125 : 125 + 32].hex() '112905df7c79c726b6250ec6e87aaa35d9127e2f48736a65c7352e8768d77865' ``` And then combined with the secret key in `secret_key_data`: ``` >>> struct.unpack(">I", data[157:161]) (64,) >>> data[161 : 161 + 32].hex() 'bc29877fa28b3e03ea6b58d35818e65a1fb4870dae35c4a452de880205108efb' >>> data[161 + 32 : 161 + 64].hex() '112905df7c79c726b6250ec6e87aaa35d9127e2f48736a65c7352e8768d77865' ``` ### Password-encrypted private key ``` -----BEGIN OPENSSH PRIVATE KEY----- b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABAkDYsc0T Z/15dYuVabmR2PAAAAEAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIP0jghIRvXtp0M55 OdbIKs7+lSrIxmEW1UDD/0Y8jtuVAAAAkFYUIM5L2+KVzTUUeFCuRP40Yafq/p+ggU7XpO MHIX0hnDHhQlRxw3s/msDvRj8nQpJ9vnswqpOljBcLYlJxh1b7j2E/MEsBja4VUudDKxft z5jwsaAxm3rTbsmWeetuJT9FGwDSsxSpOva7gGyb5kE3ywsNW6jqDBwbjCUYXr8FDn16Xd QLcaVrrPWQEb1A7Q== -----END OPENSSH PRIVATE KEY----- ``` This time the sample key has cipher_name = "aes256-ctr", kdf_name = "bcrypt": ``` >>> struct.unpack(">I", data[15:19]) (10,) >>> data[19 : 19 + 10] b'aes256-ctr' >>> struct.unpack(">I", data[29:33]) (6,) >>> data[33 : 33 + 6] b'bcrypt' ``` The KDF options aren't empty this time. They include a 16-byte salt for bcrypt, and a count of rounds (16). ``` >>> struct.unpack(">I", data[39:43]) (24,) >>> struct.unpack(">I", data[43:47]) (16,) >>> salt = data[47 : 47 + 16] >>> salt.hex() '240d8b1cd1367fd79758b9569b991d8f' >>> struct.unpack(">I", data[47 + 16 : 47 + 20]) (16,) ``` And this time, the `secret_key_possibly_encrypted` is, indeed, encrypted: ``` >>> struct.unpack(">I", data[126:130]) (144,) >>> encrypted = data[130 : 130 + 144] >>> encrypted.hex() '561420ce4bdbe295cd35147850ae44fe3461a7eafe9fa0814ed7a4e307217d219c31e1425471c37b3f9ac0ef463f2742927dbe7b30aa93a58c170b6252718756fb8f613f304b018dae1552e7432b17edcf98f0b1a0319b7ad36ec99679eb6e253f451b00d2b314a93af6bb806c9be64137cb0b0d5ba8ea0c1c1b8c25185ebf050e7d7a5dd40b71a56bacf59011bd40ed' ``` We need to use bcrypt in KDF mode, which is a special mode made up by openssh. The python "bcrypt" module supports it, and the rust module "bcrypt-pbkdf" which bitbottle uses. - The password for this file is "password". - The salt & round count comes from the KDF options above. - AES-256 in CTR mode uses a 32-byte key and 16 byte IV ("nonce"), so we need to ask bcrypt to generate 40 bytes of key material. ``` >>> generated = bcrypt.kdf(password = b"password", salt = salt, desired_key_bytes = 48, rounds = 16) >>> generated.hex() '016129df9f55f9a962d0f24bd09c2c9df2081e47663b1835090cfbab32a42b4b2651e759166186e66cd422cd381b5e58' ``` The python AES library is a bit weird with the IV for CTR mode: it wants the IV as a large 128-bit number instead of as a `bytes` object, so we have to decode it first. ``` >>> iv_hi, iv_lo = struct.unpack(">QQ", generated[32:48]) >>> iv = (iv_hi << 64) + iv_lo >>> hex(iv) '0x2651e759166186e66cd422cd381b5e58' >>> key = generated[0:32] >>> aes = Crypto.Cipher.AES.new(key, AES.MODE_CTR, counter = Crypto.Util.Counter.new(128, initial_value = iv)) >>> decrypted = aes.decrypt(encrypted) >>> decrypted.hex() 'ff8ffc81ff8ffc810000000b7373682d6564323535313900000020fd23821211bd7b69d0ce7939d6c82acefe952ac8c66116d540c3ff463c8edb9500000040dc37b71a4c7c728bf8d002b94685c79e8a28c3664b54d47ca6c2a427d57c014afd23821211bd7b69d0ce7939d6c82acefe952ac8c66116d540c3ff463c8edb950000000c726f62657940746f6775736101' ``` And now the key material is visible (different key, same decoding logic): ``` >>> struct.unpack(">I", decrypted[59:63]) (64,) >>> decrypted[63 : 63 + 32].hex() 'dc37b71a4c7c728bf8d002b94685c79e8a28c3664b54d47ca6c2a427d57c014a' >>> decrypted[63 + 32 : 63 + 64].hex() 'fd23821211bd7b69d0ce7939d6c82acefe952ac8c66116d540c3ff463c8edb95' ```