class PuTTY::Key::PPK

Represents a PuTTY private key (.ppk) file.

The {PPK} {#initialize 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. {PPK#save} defaults to format 2. Use the format parameter to select format 3.

libargon2 (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 github.com/philr/argon2-windows/releases - use either Argon2OptDll.dll for CPUs supporting AVX or Argon2RefDll.dll otherwise.

Constants

DEFAULT_ARGON2_PARAMS

Default Argon2 key derivation parameters for use with format 3.

DEFAULT_ENCRYPTION_TYPE

The default (and only supported) encryption algorithm.

DEFAULT_FORMAT

The default PuTTY private key file format.

FORMAT_2_MAC_KEY

String used in the computation of the format 3 MAC.

FORMAT_3_MAC_KEY_LENGTH

Length of the key used for the format 3 MAC.

MAXIMUM_FORMAT

The maximum supported PuTTY private key file format.

MINIMUM_FORMAT

The mimimum supported PuTTY private key file format.

Attributes

algorithm[RW]

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

@return [String] The key’s algorithm, for example, ‘ssh-rsa’ or ‘ssh-dss’.

comment[RW]

A comment to describe the PuTTY private key.

@return [String] A comment to describe the PuTTY private key.

private_blob[RW]

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.

@return [String] The private component of the key

public_blob[RW]

Get or set the public component of the key.

@return [String] The public component of the key.

Public Class Methods

new(path_or_io = nil, passphrase = nil) click to toggle source

Constructs a new {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.

@param path_or_io [Object] 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 {PPK} instance uninitialized. @param passphrase [String] The passphrase to use when loading an encrypted .ppk file.

@raise [Errno::ENOENT] If the file specified by path does not exist. @raise [ArgumentError] If the .ppk file was encrypted, but either no passphrase or an incorrect passphrase was supplied. @raise [FormatError] If the .ppk file is malformed or not supported. @raise [LoadError] If opening an encrypted format 3 .ppk file and libargon2 could not be loaded. @raise [Argon2Error] If opening an encrypted format 3 .ppk file and libargon2 reported an error hashing the passphrase.

# 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

Public Instance Methods

save(path_or_io, passphrase = nil, encryption_type: DEFAULT_ENCRYPTION_TYPE, format: DEFAULT_FORMAT, argon2_params: DEFAULT_ARGON2_PARAMS) click to toggle source

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}.

@param path_or_io [Object] The path to write to as a String or Pathname, or an IO-like instance to write to. @param passphrase [String] Set passphrase to encrypt the .ppk file using the specified passphrase. Leave as nil to create an unencrypted .ppk file. @param encryption_type [String] The encryption algorithm to use. Defaults to and currently only supports 'aes256-cbc'. @param format [Integer] The format of .ppk file to create. Defaults to 2. Supports 2 and 3. @param argon2_params [Argon2Params] The parameters to use with Argon2 to derive the encryption key, initialization vector and MAC key when saving an encrypted format 3 .ppk file.

@return [Integer] The number of bytes written to the file.

@raise [InvalidStateError] If either of the {#algorithm}, {#private_blob} or {#public_blob} attributes have not been set. @raise [ArgumentError] If path is nil. @raise [ArgumentError] If a passphrase has been specified and encryption_type is not 'aes256-cbc'. @raise [ArgumentError] If format is not 2 or 3. @raise [ArgumentError] If argon2_params is nil, a passphrase has been specified and format is 3. @raise [Errno::ENOENT] If a directory specified by path does not exist. @raise [LoadError] If saving an encrypted format 3 .ppk file and libargon2 could not be loaded. @raise [Argon2Error] If saving an encrypted format 3 .ppk file and libargon2 reported an error hashing the passphrase.

# 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

Private Instance Methods

argon2_hash(type, iterations, memory, parallelism, passphrase, salt, hash) click to toggle source

Calls the libargon2 argon2_hash function to obtain a raw hash using version 13 of the algorithm.

@param type [Symbol] The variant of Argon2 to use. (:d, :i or :id). @param iterations [Integer] The number of iterations to use. @param memory [Integer] Memory usage in kibibytes. @param passhrase [FFI::MemoryPointer] The passphrase. @param salt [FFI::MemoryPointer] The salt. @param hash [FFI::MemoryPointer] A buffer to write the raw hash to.

@raise [Argon2Error] If argon2_hash returns an error.

# File lib/putty/key/ppk.rb, line 442
def argon2_hash(type, iterations, memory, parallelism, passphrase, salt, hash)
  res = Libargon2.argon2_hash(iterations, memory, parallelism,
    passphrase, passphrase.size, salt, salt.size,
    hash, hash.size, FFI::Pointer::NULL, 0, type, :version_13)

  unless res == Libargon2::ARGON2_OK
    raise Argon2Error.new(res, Libargon2.argon2_error_message(res))
  end
end
compute_private_mac(format, mac_key, encryption_type, padded_private_blob) click to toggle source

Computes the value of the Private-MAC field given the passphrase, encryption type and padded private blob (the value of the private blob after padding bytes have been appended prior to encryption).

@param format [Integer] The format of the .ppk file. @param mac_key [String] The HMAC key. @param encryption_type [String] The value of the Encryption field. @param padded_private_blob [String] The private blob after padding bytes have been appended prior to encryption.

@return [String] The computed private MAC.

# File lib/putty/key/ppk.rb, line 502
def compute_private_mac(format, mac_key, encryption_type, padded_private_blob)
  digest = format <= 2 ? ::OpenSSL::Digest::SHA1 : ::OpenSSL::Digest::SHA256
  data = Util.ssh_pack(@algorithm, encryption_type, @comment || '', @public_blob, padded_private_blob)
  ::OpenSSL::HMAC.hexdigest(digest.new, mac_key, data)
end
derive_format_2_encryption_key(passphrase, key_length) click to toggle source

Derives an encryption key of the specified length from a passphrase for use in format 2 files.

@param passphrase [String] The passphrase to use. @param key_length [Integer] The length of the desired key in bytes.

@return [String] The derived encryption key.

# File lib/putty/key/ppk.rb, line 459
def derive_format_2_encryption_key(passphrase, key_length)
  key = String.new
  key_digest = ::OpenSSL::Digest::SHA1.new
  iteration = 0

  while true
    key_digest.update([iteration].pack('N'))
    key_digest.update(passphrase.bytes.pack('c*'))
    key += key_digest.digest

    break if key.bytesize > key_length

    key_digest.reset
    iteration += 1
  end

  key[0, key_length]
end
derive_format_2_mac_key(passphrase) click to toggle source

Derives a MAC key from a passphrase for use in format 2 files.

@param passphrase [String] The passphrase to use or nil if not encrypted.

@return [String] The derived MAC key.

# File lib/putty/key/ppk.rb, line 484
def derive_format_2_mac_key(passphrase)
  key = ::OpenSSL::Digest::SHA1.new
  key.update(FORMAT_2_MAC_KEY)
  key.update(passphrase) if passphrase
  key.digest
end
derive_format_3_keys(cipher, passphrase, argon2_params) click to toggle source

Initializes the Argon2 salt if required, determines the number of passes to use to meet the time requirement unless preset and then derives the MAC key, encryption key and initalization vector.

@param cipher [OpenSSL::Cipher] The cipher being used to encrypt or decrypt the .ppk file. @param passphrase [String] The passphrase used in the derivation. The raw bytes of the passphrase are used in the derivation. @param argon2_params [Argon2Params] Parameters used with the Argon2 hash function.

@return [Array<String, String, String, Argon2Params>] The MAC key, encryption key, initialization vector and final Argon2 parameters. The encryption key and initialization vector will be nil if cipher is nil. The final Argon2 parameters will differ from argon2_params if the salt and passes options were left unspecified.

@raise [LoadError] If libargon2 could not be loaded. @raise [Argon2Error] If libargon2 reported an error hashing the passphrase.

# File lib/putty/key/ppk.rb, line 370
def derive_format_3_keys(cipher, passphrase, argon2_params)
  # Defer loading of libargon2 to avoid a mandatory dependency.
  require_relative 'libargon2'

  salt = argon2_params.salt || ::OpenSSL::Random.random_bytes(16)
  passphrase_ptr = pointer_for_bytes(passphrase)
  salt_ptr = pointer_for_bytes(salt)
  hash_ptr = FFI::MemoryPointer.new(:char, cipher.key_len + cipher.iv_len + FORMAT_3_MAC_KEY_LENGTH)
  begin
    passes = argon2_params.passes
    if passes
      argon2_hash(argon2_params.type, argon2_params.passes, argon2_params.memory, argon2_params.parallelism, passphrase_ptr, salt_ptr, hash_ptr)
    else
      # Only require the time taken to be approximately correct. Scale up
      # geometrically using Fibonacci numbers (as per PuTTY's
      # implementation).
      prev_passes = 1
      passes = 1

      loop do
        start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
        argon2_hash(argon2_params.type, passes, argon2_params.memory, argon2_params.parallelism, passphrase_ptr, salt_ptr, hash_ptr)
        elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000
        break if (elapsed >= argon2_params.desired_time)
        hash_ptr.clear
        new_passes = passes + prev_passes
        break if new_passes > 2**32 # maximum allowed by argon2_hash parameter data type
        prev_passes, passes = passes, new_passes
      end
    end

    passphrase_ptr.clear
    key = hash_ptr.get_bytes(0, cipher.key_len)
    iv = hash_ptr.get_bytes(cipher.key_len, cipher.iv_len)
    mac_key = hash_ptr.get_bytes(cipher.key_len + cipher.iv_len, FORMAT_3_MAC_KEY_LENGTH)
    argon2_params = argon2_params.complete(passes, salt)
    hash_ptr.clear
    [mac_key, key, iv, argon2_params]
  ensure
    # Calling free isn't actually required, but this releases the memory
    # sooner.
    hash_ptr.free
    salt_ptr.free
    passphrase_ptr.free
  end
end
derive_keys(format, cipher = nil, passphrase = nil, argon2_params = nil) click to toggle source

Derives the MAC key, encryption key and initialization vector from the passphrase (if the file is encrypted).

@param format [Integer] The format of the .ppk file. @param cipher [OpenSSL::Cipher] The cipher being used to encrypt or decrypt the .ppk file or nil if not encrypted. @param passphrase [String] The passphrase used in the derivation or nil if the .ppk file is not encrypted. The raw bytes of the passphrase are used in the derivation. @param argon2_params [Argon2Params] Parameters used with the Argon2 hash function. May be nil if the .ppk file is not encrypted or format is less than 3.

@return [Array<String, String, String, Argon2Params>] The MAC key, encryption key, initialization vector and final Argon2 parameters. The encryption key and initialization vector will be nil if cipher is nil. The final Argon2 parameters will only be set if format is greater than or equal to 3 and cipher is not nil. The final Argon2 parameters will differ from argon2_params if the salt and passes options were left unspecified.

@raise [LoadError] If format is at least 3, cipher is specified and libargon2 could not be loaded. @raise [Argon2Error] If format is at least 3, cipher is specified and libargon2 reported an error hashing the passphrase.

# File lib/putty/key/ppk.rb, line 319
def derive_keys(format, cipher = nil, passphrase = nil, argon2_params = nil)
  if format >= 3
    return derive_format_3_keys(cipher, passphrase, argon2_params) if cipher

    # An empty string should work for the MAC, but ::OpenSSL::HMAC fails
    # when used with OpenSSL 3:
    #
    #   EVP_PKEY_new_mac_key: malloc failure (OpenSSL::HMACError).
    #
    # See https://github.com/ruby/openssl/pull/538 and
    # https://github.com/openssl/openssl/issues/13089.
    #
    # Ruby 3.1.3 should contain the workaround from ruby/openssl PR 538.
    #
    # Use "\0" as the MAC key for a workaround for Ruby < 3.1.3.
    return ["\0".b, nil, nil, nil]
  end

  mac_key = derive_format_2_mac_key(passphrase)

  if cipher
    key = derive_format_2_encryption_key(passphrase, cipher.key_len)
    iv = "\0".b * cipher.iv_len
  else
    key = nil
    iv = nil
  end

  [mac_key, key, iv, nil]
end
get_argon2_type(key_derivation) click to toggle source

Returns the Argon2 type (:d, :i or :id) corresponding to the value of the Key-Derivation field in the .ppk file.

@param key_derivation [String] The value of the Key-Derivation field.

@return [Symbol] The Argon2 type.

@raise [FormatError] If key_derivation is unrecognized.

# File lib/putty/key/ppk.rb, line 286
def get_argon2_type(key_derivation)
  unless key_derivation =~ /\AArgon2(d|id?)\z/
    raise FormatError, "Unrecognized key derivation type: #{key_derivation}"
  end

  $1.to_sym
end
pointer_for_bytes(string) click to toggle source

Creates an FFI::MemoryPointer containing the raw bytes from string without a null terminator.

@param string [String] The bytes to use for the FFI::MemoryPointer.

@return [FFI::MemoryPointer] A new FFI::MemoryPointer containing the raw bytes from string.

# File lib/putty/key/ppk.rb, line 424
def pointer_for_bytes(string)
  FFI::MemoryPointer.new(:char, string.bytesize).tap do |ptr|
    ptr.put_bytes(0, string)
  end
end