Class: PuTTY::Key::PPK

Inherits:
Object
  • Object
show all
Defined in:
lib/putty/key/ppk.rb

Overview

Represents a PuTTY private key (.ppk) file.

The PPK constructor can be used to either create an uninitialized key or to read a .ppk file (from file or an IO-like instance).

The #save method can be used to write a PPK instance to a .ppk file or IO-like instance.

The #algorithm, #comment, #public_blob and #private_blob attributes provide access to the high level fields of the PuTTY private key as binary String instances. The structure of the two blobs will vary based on the algorithm.

Encrypted .ppk files can be read and written by specifying a passphrase when loading or saving. Files are encrypted using AES in CBC mode with a 256-bit key derived from the passphrase.

The PPK class supports files corresponding to PuTTY's formats 2 and 3. Format 1 (not supported) was only used briefly early on in the development of the .ppk format and was never released. Format 2 is supported by PuTTY version 0.52 onwards. Format 3 is supported by PuTTY version 0.75 onwards. #save defaults to format 2. Use the format parameter to select format 3.

libargon2 (https://github.com/P-H-C/phc-winner-argon2) is required to load and save encrypted format 3 files. Binaries are typically available with your OS distribution. For Windows, binaries are available at https://github.com/philr/argon2-windows/releases - use either Argon2OptDll.dll for CPUs supporting AVX or Argon2RefDll.dll otherwise.

Constant Summary collapse

DEFAULT_ENCRYPTION_TYPE =

The default (and only supported) encryption algorithm.

'aes256-cbc'.freeze
DEFAULT_FORMAT =

The default PuTTY private key file format.

2
MINIMUM_FORMAT =

The mimimum supported PuTTY private key file format.

2
MAXIMUM_FORMAT =

The maximum supported PuTTY private key file format.

3
DEFAULT_ARGON2_PARAMS =

Default Argon2 key derivation parameters for use with format 3.

Argon2Params.new.freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(path_or_io = nil, passphrase = nil) ⇒ PPK

Constructs a new PuTTY::Key::PPK instance either uninitialized, or initialized by reading from a .ppk file or an IO-like instance.

To read from a file set path_or_io to the file path, either as a String or a Pathname. To read from an IO-like instance set path_or_io to the instance. The instance must respond to #read. #binmode will be called before reading if supported by the instance.

Parameters:

  • path_or_io (Object) (defaults to: nil)

    Set to the path of a .ppk file to load the file as a String or Pathname, or an IO-like instance to read the .ppk file from that instance. Set to nil to leave the new PuTTY::Key::PPK instance uninitialized.

  • passphrase (String) (defaults to: nil)

    The passphrase to use when loading an encrypted .ppk file.

Raises:

  • (Errno::ENOENT)

    If the file specified by path does not exist.

  • (ArgumentError)

    If the .ppk file was encrypted, but either no passphrase or an incorrect passphrase was supplied.

  • (FormatError)

    If the .ppk file is malformed or not supported.

  • (LoadError)

    If opening an encrypted format 3 .ppk file and libargon2 could not be loaded.

  • (Argon2Error)

    If opening an encrypted format 3 .ppk file and libargon2 reported an error hashing the passphrase.



109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
# File 'lib/putty/key/ppk.rb', line 109

def initialize(path_or_io = nil, passphrase = nil)
  passphrase = nil if passphrase && passphrase.to_s.empty?

  if path_or_io
    Reader.open(path_or_io) do |reader|
      format, @algorithm = reader.field_matching(/PuTTY-User-Key-File-(\d+)/)
      format = format.to_i
      raise FormatError, "The ppk file is using a format that is too new (#{format})" if format > MAXIMUM_FORMAT
      raise FormatError, "The ppk file is using an old unsupported format (#{format})" if format < MINIMUM_FORMAT

      encryption_type = reader.field('Encryption')
      @comment = reader.field('Comment')
      @public_blob = reader.blob('Public')


      if encryption_type == 'none'
        passphrase = nil
        mac_key = derive_keys(format).first
        @private_blob = reader.blob('Private')
      else
        raise FormatError, "The ppk file is encrypted with #{encryption_type}, which is not supported" unless encryption_type == DEFAULT_ENCRYPTION_TYPE
        raise ArgumentError, 'The ppk file is encrypted, a passphrase must be supplied' unless passphrase

        argon2_params = if format >= 3
          type = get_argon2_type(reader.field('Key-Derivation'))
          memory = reader.unsigned_integer('Argon2-Memory', maximum: 2**32)
          passes = reader.unsigned_integer('Argon2-Passes', maximum: 2**32)
          parallelism = reader.unsigned_integer('Argon2-Parallelism', maximum: 2**32)
          salt = reader.field('Argon2-Salt')
          unless salt =~ /\A(?:[0-9a-fA-F]{2})+\z/
            raise FormatError, "Expected the Argon2-Salt field to be a hex string, but found #{salt}"
          end

          Argon2Params.new(type: type, memory: memory, passes: passes, parallelism: parallelism, salt: [salt].pack('H*'))
        end

        cipher = ::OpenSSL::Cipher::AES.new(256, :CBC)
        cipher.decrypt
        mac_key, cipher.key, cipher.iv = derive_keys(format, cipher, passphrase, argon2_params)
        cipher.padding = 0
        encrypted_private_blob = reader.blob('Private')

        @private_blob = if encrypted_private_blob.bytesize > 0
          partial = cipher.update(encrypted_private_blob)
          final = cipher.final
          partial + final
        else
          encrypted_private_blob
        end
      end

      private_mac = reader.field('Private-MAC')
      expected_private_mac = compute_private_mac(format, mac_key, encryption_type, @private_blob)

      unless private_mac == expected_private_mac
        raise ArgumentError, 'Incorrect passphrase supplied' if passphrase
        raise FormatError, "Invalid Private MAC (expected #{expected_private_mac}, but found #{private_mac})"
      end
    end
  end
end

Instance Attribute Details

#algorithmString

The key's algorithm, for example, 'ssh-rsa' or 'ssh-dss'.

Returns:

  • (String)

    The key's algorithm, for example, 'ssh-rsa' or 'ssh-dss'.



65
66
67
# File 'lib/putty/key/ppk.rb', line 65

def algorithm
  @algorithm
end

#commentString

A comment to describe the PuTTY private key.

Returns:

  • (String)

    A comment to describe the PuTTY private key.



70
71
72
# File 'lib/putty/key/ppk.rb', line 70

def comment
  @comment
end

#private_blobString

The private component of the key (after decryption when loading and before encryption when saving).

Note that when loading an encrypted .ppk file, this may include additional 'random' suffix used as padding.

Returns:

  • (String)

    The private component of the key



84
85
86
# File 'lib/putty/key/ppk.rb', line 84

def private_blob
  @private_blob
end

#public_blobString

Get or set the public component of the key.

Returns:

  • (String)

    The public component of the key.



75
76
77
# File 'lib/putty/key/ppk.rb', line 75

def public_blob
  @public_blob
end

Instance Method Details

#save(path_or_io, passphrase = nil, encryption_type: DEFAULT_ENCRYPTION_TYPE, format: DEFAULT_FORMAT, argon2_params: DEFAULT_ARGON2_PARAMS) ⇒ Integer

Writes this PuTTY private key instance to a .ppk file or IO-like instance.

To write to a file, set path_or_io to the file path, either as a String or a Pathname. To write to an IO-like instance set path_or_io to the instance. The instance must respond to #write. #binmode will be called before writing if supported by the instance.

If a file with the given path already exists, it will be overwritten.

The #algorithm, #private_blob and #public_blob attributes must have been set before calling #save.

Parameters:

  • path_or_io (Object)

    The path to write to as a String or Pathname, or an IO-like instance to write to.

  • passphrase (String) (defaults to: nil)

    Set passphrase to encrypt the .ppk file using the specified passphrase. Leave as nil to create an unencrypted .ppk file.

  • encryption_type (String) (defaults to: DEFAULT_ENCRYPTION_TYPE)

    The encryption algorithm to use. Defaults to and currently only supports 'aes256-cbc'.

  • format (Integer) (defaults to: DEFAULT_FORMAT)

    The format of .ppk file to create. Defaults to 2. Supports 2 and 3.

  • argon2_params (Argon2Params) (defaults to: DEFAULT_ARGON2_PARAMS)

    The parameters to use with Argon2 to derive the encryption key, initialization vector and MAC key when saving an encrypted format 3 .ppk file.

Returns:

  • (Integer)

    The number of bytes written to the file.

Raises:

  • (InvalidStateError)

    If either of the #algorithm, #private_blob or #public_blob attributes have not been set.

  • (ArgumentError)

    If path is nil.

  • (ArgumentError)

    If a passphrase has been specified and encryption_type is not 'aes256-cbc'.

  • (ArgumentError)

    If format is not 2 or 3.

  • (ArgumentError)

    If argon2_params is nil, a passphrase has been specified and format is 3.

  • (Errno::ENOENT)

    If a directory specified by path does not exist.

  • (LoadError)

    If saving an encrypted format 3 .ppk file and libargon2 could not be loaded.

  • (Argon2Error)

    If saving an encrypted format 3 .ppk file and libargon2 reported an error hashing the passphrase.



213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
# File 'lib/putty/key/ppk.rb', line 213

def save(path_or_io, passphrase = nil, encryption_type: DEFAULT_ENCRYPTION_TYPE, format: DEFAULT_FORMAT, argon2_params: DEFAULT_ARGON2_PARAMS)
  raise InvalidStateError, 'algorithm must be set before calling save' unless @algorithm
  raise InvalidStateError, 'public_blob must be set before calling save' unless @public_blob
  raise InvalidStateError, 'private_blob must be set before calling save' unless @private_blob

  raise ArgumentError, 'An output path or io instance must be specified' unless path_or_io

  passphrase = nil if passphrase && passphrase.to_s.empty?

  raise ArgumentError, 'A format must be specified' unless format
  raise ArgumentError, "Unsupported format: #{format}" unless format >= MINIMUM_FORMAT && format <= MAXIMUM_FORMAT

  if passphrase
    raise ArgumentError, 'An encryption_type must be specified if a passphrase is specified' unless encryption_type
    raise ArgumentError, "Unsupported encryption_type: #{encryption_type}" unless encryption_type == DEFAULT_ENCRYPTION_TYPE
    raise ArgumentError, 'argon2_params must be specified if a passphrase is specified with format 3' unless format < 3 || argon2_params

    cipher = ::OpenSSL::Cipher::AES.new(256, :CBC)
    cipher.encrypt
    mac_key, cipher.key, cipher.iv, kdf_params = derive_keys(format, cipher, passphrase, argon2_params)
    cipher.padding = 0

    # Pad using an SHA-1 hash of the unpadded private blob in order to
    # prevent an easily known plaintext attack on the last block.
    padding_length = cipher.block_size - ((@private_blob.bytesize - 1) % cipher.block_size) - 1
    padded_private_blob = @private_blob
    padded_private_blob += ::OpenSSL::Digest::SHA1.new(@private_blob).digest.byteslice(0, padding_length) if padding_length > 0

    encrypted_private_blob = if padded_private_blob.bytesize > 0
      partial = cipher.update(padded_private_blob)
      final = cipher.final
      partial + final
    else
      padded_private_blob
    end
  else
    encryption_type = 'none'
    mac_key = derive_keys(format).first
    kdf_params = nil
    padded_private_blob = @private_blob
    encrypted_private_blob = padded_private_blob
  end

  private_mac = compute_private_mac(format, mac_key, encryption_type, padded_private_blob)

  Writer.open(path_or_io) do |writer|
    writer.field("PuTTY-User-Key-File-#{format}", @algorithm)
    writer.field('Encryption', encryption_type)
    writer.field('Comment', @comment)
    writer.blob('Public', @public_blob)
    if kdf_params
      # Only Argon2 is currently supported.
      writer.field('Key-Derivation', "Argon2#{kdf_params.type}")
      writer.field('Argon2-Memory', kdf_params.memory)
      writer.field('Argon2-Passes', kdf_params.passes)
      writer.field('Argon2-Parallelism', kdf_params.parallelism)
      writer.field('Argon2-Salt', kdf_params.salt.unpack('H*').first)
    end
    writer.blob('Private', encrypted_private_blob)
    writer.field('Private-MAC', private_mac)
  end
end