putty-key 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data.tar.gz.sig +0 -0
  4. data/CHANGES.md +16 -0
  5. data/Gemfile +0 -6
  6. data/LICENSE +1 -1
  7. data/README.md +32 -6
  8. data/Rakefile +24 -0
  9. data/lib/putty/key.rb +6 -6
  10. data/lib/putty/key/argon2_params.rb +101 -0
  11. data/lib/putty/key/error.rb +17 -0
  12. data/lib/putty/key/libargon2.rb +54 -0
  13. data/lib/putty/key/ppk.rb +469 -103
  14. data/lib/putty/key/util.rb +10 -10
  15. data/lib/putty/key/version.rb +1 -1
  16. data/putty-key.gemspec +11 -2
  17. data/test/argon2_params_test.rb +144 -0
  18. data/test/fixtures/{dss-1024-encrypted.ppk → dss-1024-encrypted-format-2.ppk} +17 -17
  19. data/test/fixtures/dss-1024-encrypted-format-3.ppk +22 -0
  20. data/test/fixtures/{dss-1024.ppk → dss-1024-format-2.ppk} +17 -17
  21. data/test/fixtures/dss-1024-format-3.ppk +17 -0
  22. data/test/fixtures/{ecdsa-sha2-nistp256-encrypted.ppk → ecdsa-sha2-nistp256-encrypted-format-2.ppk} +10 -10
  23. data/test/fixtures/ecdsa-sha2-nistp256-encrypted-format-3.ppk +15 -0
  24. data/test/fixtures/{ecdsa-sha2-nistp256.ppk → ecdsa-sha2-nistp256-format-2.ppk} +10 -10
  25. data/test/fixtures/ecdsa-sha2-nistp256-format-3.ppk +10 -0
  26. data/test/fixtures/{ecdsa-sha2-nistp384-encrypted.ppk → ecdsa-sha2-nistp384-encrypted-format-2.ppk} +11 -11
  27. data/test/fixtures/ecdsa-sha2-nistp384-encrypted-format-3.ppk +16 -0
  28. data/test/fixtures/{ecdsa-sha2-nistp384.ppk → ecdsa-sha2-nistp384-format-2.ppk} +11 -11
  29. data/test/fixtures/ecdsa-sha2-nistp384-format-3.ppk +11 -0
  30. data/test/fixtures/{ecdsa-sha2-nistp521-encrypted.ppk → ecdsa-sha2-nistp521-encrypted-format-2.ppk} +12 -12
  31. data/test/fixtures/ecdsa-sha2-nistp521-encrypted-format-3.ppk +17 -0
  32. data/test/fixtures/{ecdsa-sha2-nistp521.ppk → ecdsa-sha2-nistp521-format-2.ppk} +12 -12
  33. data/test/fixtures/ecdsa-sha2-nistp521-format-3.ppk +12 -0
  34. data/test/fixtures/{rsa-2048-encrypted.ppk → rsa-2048-encrypted-format-2.ppk} +26 -26
  35. data/test/fixtures/rsa-2048-encrypted-format-3.ppk +31 -0
  36. data/test/fixtures/{rsa-2048.ppk → rsa-2048-format-2.ppk} +26 -26
  37. data/test/fixtures/rsa-2048-format-3.ppk +26 -0
  38. data/test/fixtures/test-blank-comment.ppk +11 -11
  39. data/test/fixtures/test-empty-blobs-encrypted.ppk +6 -0
  40. data/test/fixtures/test-empty-blobs.ppk +6 -0
  41. data/test/fixtures/{test-encrypted.ppk → test-encrypted-format-2.ppk} +11 -11
  42. data/test/fixtures/test-encrypted-format-3.ppk +16 -0
  43. data/test/fixtures/test-encrypted-type-d-format-3.ppk +16 -0
  44. data/test/fixtures/test-encrypted-type-i-format-3.ppk +16 -0
  45. data/test/fixtures/{test-unix-line-endings.ppk → test-format-2.ppk} +0 -0
  46. data/test/fixtures/test-format-3.ppk +11 -0
  47. data/test/fixtures/test-invalid-argon2-memory-for-libargon2.ppk +16 -0
  48. data/test/fixtures/test-invalid-argon2-memory-maximum.ppk +16 -0
  49. data/test/fixtures/test-invalid-argon2-memory.ppk +16 -0
  50. data/test/fixtures/test-invalid-argon2-parallelism-maximum.ppk +16 -0
  51. data/test/fixtures/test-invalid-argon2-parallelism.ppk +16 -0
  52. data/test/fixtures/test-invalid-argon2-passes-maximum.ppk +16 -0
  53. data/test/fixtures/test-invalid-argon2-passes.ppk +16 -0
  54. data/test/fixtures/test-invalid-argon2-salt.ppk +16 -0
  55. data/test/fixtures/test-invalid-blob-lines.ppk +11 -11
  56. data/test/fixtures/test-invalid-encryption-type.ppk +11 -11
  57. data/test/fixtures/test-invalid-format-1.ppk +11 -11
  58. data/test/fixtures/{test-invalid-format-3.ppk → test-invalid-format-4.ppk} +11 -11
  59. data/test/fixtures/test-invalid-key-derivation.ppk +16 -0
  60. data/test/fixtures/test-invalid-private-mac.ppk +11 -11
  61. data/test/fixtures/test-legacy-mac-line-endings.ppk +1 -0
  62. data/test/fixtures/test-missing-final-line-ending.ppk +11 -0
  63. data/test/fixtures/test-truncated.ppk +10 -10
  64. data/test/fixtures/{test.ppk → test-windows-line-endings.ppk} +0 -0
  65. data/test/openssl_test.rb +243 -53
  66. data/test/ppk_test.rb +325 -44
  67. metadata +73 -23
  68. metadata.gz.sig +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9ee468e63692d4e452273f75c0e2349f451992fa906901f8856685c8c8f2bf82
4
- data.tar.gz: 4aafcf168d89410aae4e6f513a10e6fb3261c4a88493ca88125a1ea251cc6721
3
+ metadata.gz: 0e875308cd1e8bd154bb40ceccf856a1b91cfa74e1858c89b006735016980c10
4
+ data.tar.gz: 9306636667b1864c570bc467837e44e3d2490b965d4c3493ff1abe853832d419
5
5
  SHA512:
6
- metadata.gz: 67187df6dd956d5067b3a97f35fe53fbb35698f788c5a08f6fd6bf42cc20afcb910fab6773f8af24ac6d53d6f9bd0c23737e47d683921147e9453296d3eed32d
7
- data.tar.gz: 7ff5c7f235975206b17da9be813221c4d24e9706e036ef4fc32ed097cfa4ce52deeb11cd9acfc68c218b0e6b7f68d07a54155e81738acd3bf05ca26f75921f03
6
+ metadata.gz: 52426f86621cff16b7a55901144e987f8ecc8478c7204511449d8de3d19258c60f472deb98e85996d39f9a3a197e2a2832095d01e09e710454f30d00c99af820
7
+ data.tar.gz: acb9f8986b8c42e554e14d54d11cb6d6bde103256780dd31a81e4b828e6109b40d03b19460479868334028a9534a05eb729d04db555678c3db7a0a12651d9b32
checksums.yaml.gz.sig CHANGED
Binary file
data.tar.gz.sig CHANGED
Binary file
data/CHANGES.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changes #
2
2
 
3
+ ## Version 1.1.0 - 24-May-2021 ##
4
+
5
+ * Add support for [format 3 .ppk files](https://www.chiark.greenend.org.uk/~sgtatham/putty/wishlist/ppk3.html)
6
+ introduced in PuTTY version 0.75. `PuTTY::Key::PPK#save` defaults to saving
7
+ format 2 files. [libargon2](https://github.com/P-H-C/phc-winner-argon2) is
8
+ required to load and save encrypted format 3 files.
9
+ * Write files using LF line endings (Unix) instead of CRLF (Windows) to match
10
+ PuTTYgen version 0.75 (versions up to 0.74 used CRLF, but are compatible with
11
+ CRLF and LF).
12
+ * Support reading files with CR line endings (Classic Mac OS).
13
+ * Support reading from and writing to `IO`-like streams.
14
+ * Allow loading and saving files with empty private or public keys.
15
+ * Fix adding unnecessary padding to the private key on saving when it is an
16
+ exact multiple of the block size.
17
+
18
+
3
19
  ## Version 1.0.1 - 26-Dec-2019 ##
4
20
 
5
21
  * Fix errors converting DSA and RSA PPK keys to OpenSSL in
data/Gemfile CHANGED
@@ -14,10 +14,4 @@ group :test do
14
14
  # coveralls_reborn is maintained, but requires Ruby >= 2.3.
15
15
  gem 'coveralls', '~> 0.8', require: false if RUBY_VERSION < '2.3'
16
16
  gem 'coveralls_reborn', '~> 0.13', require: false if RUBY_VERSION >= '2.3'
17
-
18
- # json is a dependency of simplecov. Version 2.3.0 is declared as compatible
19
- # with Ruby >= 1.9, but actually fails with a syntax error.
20
- #
21
- # Limit to earlier versions on Ruby 1.9.
22
- gem 'json', '< 2.3.0', require: false if RUBY_VERSION < '2.0'
23
17
  end
data/LICENSE CHANGED
@@ -1,4 +1,4 @@
1
- Copyright (c) 2016-2019 Philip Ross
1
+ Copyright (c) 2016-2021 Philip Ross
2
2
 
3
3
  Permission is hereby granted, free of charge, to any person obtaining a copy of
4
4
  this software and associated documentation files (the "Software"), to deal in
data/README.md CHANGED
@@ -1,12 +1,13 @@
1
1
  # PuTTY::Key #
2
2
 
3
- [![Gem Version](https://badge.fury.io/rb/putty-key.svg)](https://badge.fury.io/rb/putty-key) [![Build Status](https://travis-ci.org/philr/putty-key.svg?branch=master)](https://travis-ci.org/philr/putty-key) [![Build status](https://ci.appveyor.com/api/projects/status/btinuu4g8sdachj3/branch/master?svg=true)](https://ci.appveyor.com/project/philr/tzinfo/branch/master) [![Coverage Status](https://coveralls.io/repos/philr/putty-key/badge.svg?branch=master)](https://coveralls.io/r/philr/putty-key?branch=master)
3
+ [![RubyGems](https://img.shields.io/gem/v/putty-key?logo=rubygems&label=Gem)](https://rubygems.org/gems/putty-key) [![Tests](https://github.com/philr/putty-key/workflows/Tests/badge.svg?branch=master&event=push)](https://github.com/philr/putty-key/actions?query=workflow%3ATests+branch%3Amaster+event%3Apush) [![Coverage Status](https://img.shields.io/coveralls/github/philr/putty-key/master?label=Coverage&logo=Coveralls)](https://coveralls.io/github/philr/putty-key?branch=master)
4
4
 
5
- PuTTY::Key is a pure-Ruby implementation of the PuTTY private key (ppk) format,
6
- handling reading and writing .ppk files. It includes a refinement to Ruby's
7
- OpenSSL library to add support for converting DSA, EC and RSA private keys to
8
- and from PuTTY private key files. This allows OpenSSH ecdsa, ssh-dss and ssh-rsa
9
- private keys to be converted to and from PuTTY's private key format.
5
+ PuTTY::Key is a Ruby implementation of the PuTTY private key (ppk) format
6
+ (versions 2 and 3), handling reading and writing .ppk files. It includes a
7
+ refinement to Ruby's OpenSSL library to add support for converting DSA, EC and
8
+ RSA private keys to and from PuTTY private key files. This allows OpenSSH ecdsa,
9
+ ssh-dss and ssh-rsa private keys to be converted to and from PuTTY's private key
10
+ format.
10
11
 
11
12
 
12
13
  ## Installation ##
@@ -29,6 +30,22 @@ gem 'putty-key'
29
30
  PuTTY::Key is compatible with Ruby MRI 2.1.0+ and JRuby 9.1.0.0+.
30
31
 
31
32
 
33
+ ## Formats ##
34
+
35
+ Format 2 and 3 .ppk files are supported. Format 1 (not supported) was only used
36
+ briefly early on in the development of the .ppk format and was never included in
37
+ a PuTTY release. Format 2 is supported by PuTTY version 0.52 onwards. Format 3
38
+ is supported by PuTTY version 0.75 onwards. By default, `PuTTY::Key::PPK` saves
39
+ files using format 2. Format 3 can be selected with the `format` parameter.
40
+
41
+ [libargon2](https://github.com/P-H-C/phc-winner-argon2) is required to load and
42
+ save encrypted format 3 files. Binaries are typically available with your OS
43
+ distribution. For Windows, binaries are available from the
44
+ [argon2-windows](https://github.com/philr/argon2-windows/releases) repository.
45
+ Use either Argon2OptDll.dll for CPUs supporting AVX or Argon2RefDll.dll
46
+ otherwise.
47
+
48
+
32
49
  ## Usage ##
33
50
 
34
51
  To use PuTTY::Key, it must first be loaded with:
@@ -68,6 +85,9 @@ ppk.comment = 'Optional comment'
68
85
  ppk.save('key.ppk')
69
86
  ```
70
87
 
88
+ Use `ppk.save('key.ppk', format: 3)` to save a format 3 file instead of
89
+ format 2.
90
+
71
91
 
72
92
  ### Generating a new RSA key and saving it as an encrypted .ppk file ###
73
93
 
@@ -82,6 +102,9 @@ ppk.comment = 'RSA 2048'
82
102
  ppk.save('rsa.ppk', 'Passphrase for encryption')
83
103
  ```
84
104
 
105
+ Use `ppk.save('rsa.ppk', 'Passphrase for encryption', format: 3)` to save a
106
+ format 3 file instead of format 2.
107
+
85
108
 
86
109
  ### Converting an unencrypted .ppk file to .pem format ###
87
110
 
@@ -106,6 +129,9 @@ ppk = PuTTY::Key::PPK.new('rsa.ppk', 'Passphrase for encryption')
106
129
  ppk.save('rsa-plain.ppk')
107
130
  ```
108
131
 
132
+ Use `ppk.save('rsa-plain.ppk', format: 3)` to save a format 3 file instead of
133
+ format 2.
134
+
109
135
 
110
136
  ## API Documentation ##
111
137
 
data/Rakefile CHANGED
@@ -105,3 +105,27 @@ end
105
105
  desc 'Run tests using the refinement, then with the global install'
106
106
  task :test => [:clean_coverage, 'test:refinement', 'test:global'] + (TEST_COVERAGE ? ['coveralls:push'] : []) do
107
107
  end
108
+
109
+ # Coveralls expects an sh compatible shell when running git commands with Kernel#`
110
+ # On Windows, the results end up wrapped in single quotes.
111
+ # Patch Coveralls::Configuration to remove the quotes.
112
+ if RUBY_PLATFORM =~ /mingw/
113
+ module CoverallsFixConfigurationOnWindows
114
+ def self.included(base)
115
+ base.instance_eval do
116
+ class << self
117
+ alias_method :git_without_windows_fix, :git
118
+
119
+ def git
120
+ git_without_windows_fix.tap do |hash|
121
+ hash[:head] = hash[:head].map {|k, v| [k, v =~ /\A'(.*)'\z/ ? $1 : v] }.to_h
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+
129
+ require 'coveralls'
130
+ Coveralls::Configuration.send(:include, CoverallsFixConfigurationOnWindows)
131
+ end
data/lib/putty/key.rb CHANGED
@@ -1,12 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PuTTY
4
- # PuTTY::Key is a pure-Ruby implementation of the PuTTY private key (ppk)
5
- # format, handling reading and writing .ppk files. It includes a refinement to
6
- # Ruby's OpenSSL library to add support for converting DSA, EC and RSA private
7
- # keys to and from PuTTY private key files. This allows OpenSSH ecdsa, ssh-dss
8
- # and ssh-rsa private keys to be converted to and from PuTTY's private key
9
- # format.
4
+ # PuTTY::Key is a Ruby implementation of the PuTTY private key (ppk) format,
5
+ # handling reading and writing .ppk files. It includes a refinement to Ruby's
6
+ # OpenSSL library to add support for converting DSA, EC and RSA private keys
7
+ # to and from PuTTY private key files. This allows OpenSSH ecdsa, ssh-dss and
8
+ # ssh-rsa private keys to be converted to and from PuTTY's private key format.
10
9
  module Key
11
10
 
12
11
  # Makes the refinements available in PuTTY::Key available globally. After
@@ -20,6 +19,7 @@ module PuTTY
20
19
  end
21
20
 
22
21
  require_relative 'key/version'
22
+ require_relative 'key/argon2_params'
23
23
  require_relative 'key/error'
24
24
  require_relative 'key/util'
25
25
  require_relative 'key/ppk'
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PuTTY
4
+ module Key
5
+ # Argon2 key derivation parameters for use with format 3.
6
+ class Argon2Params
7
+ # Returns the variant of Argon2 to use. `:d` for Argon2d, `:i` for Argon2i
8
+ # or `:id` for Argon2id.
9
+ #
10
+ # @return [Symbol] The variant of Argon2 to use (`:d`, `:i` or `:id`).
11
+ attr_reader :type
12
+
13
+ # @return [Integer] The amount of memory to use (memory cost) in
14
+ # kibibytes.
15
+ attr_reader :memory
16
+
17
+ # @return [Integer] The number of parallel threads to use (parallelism
18
+ # degree / lanes).
19
+ attr_reader :parallelism
20
+
21
+ # @return [Integer] The number of passes or iterations to run (time cost),
22
+ # or `nil` to determine the time cost based on {#desired_time}.
23
+ attr_reader :passes
24
+
25
+ # @return [String] The salt to use, or `nil` if a random salt should be
26
+ # selected.
27
+ attr_reader :salt
28
+
29
+ # The minimum time that should be taken to derive keys in milliseconds.
30
+ # Only used if {#passes} is `nil`.
31
+ #
32
+ # A number of passes will be chosen that take at least {#desired_time} to
33
+ # compute a hash.
34
+ #
35
+ # @return [Numeric] The minimum time that should be taken to derive keys
36
+ # in milliseconds.
37
+ attr_reader :desired_time
38
+
39
+ # Initalizes a new {Argon2Params} instance with the specified parameters.
40
+ #
41
+ # @param type [Symbol] The variant of Argon2 to use (`:d`, `:i` or `:id`).
42
+ # @param memory [Integer] The amount of memory to use (memory cost) in
43
+ # kibibytes.
44
+ # @param parallelism [Integer] The number of parallel threads to use
45
+ # (parallelism degree / lanes).
46
+ # @param passes [Integer] The number of passes or iterations to run (time
47
+ # cost), or `nil` to determine the time cost based on {#desired_time}.
48
+ # @param salt [String] The salt to use, or `nil` if a random salt should
49
+ # be selected.
50
+ # @param desired_time [Numeric] The minimum time that should be taken to
51
+ # derive keys in milliseconds.
52
+ #
53
+ # @raise [ArgumentError] If `type` is not either `:d`, `:i` or `:id`.
54
+ # @raise [ArgumentError] If `memory` is not an `Integer`, is negative or
55
+ # greater than 2³².
56
+ # @raise [ArgumentError] If `parallelism` is not an `Integer`, is negative
57
+ # or greater than 2³².
58
+ # @raise [ArgumentError] If `passes` is specified, but is not an
59
+ # `Integer`, is negative or greater than 2³².
60
+ # @raise [ArgumentError] If `salt` is specified, but is not a `String`.
61
+ # @raise [ArgumentError] If `desired_time` is not `Numeric` or is
62
+ # negative.
63
+ def initialize(type: :id, memory: 8192, parallelism: 1, passes: nil, salt: nil, desired_time: 100)
64
+ raise ArgumentError, 'type must be :d, :i or :id' unless type == :id || type == :i || type == :d
65
+ raise ArgumentError, 'memory must be a non-negative Integer' unless memory.kind_of?(Integer) && memory >= 0 && memory <= 2**32
66
+ raise ArgumentError, 'parallelism must be a non-negative Integer' unless parallelism.kind_of?(Integer) && parallelism >= 0 && parallelism <= 2**32
67
+ raise ArgumentError, 'passes must be nil or a non-negative Integer' if passes && !(passes.kind_of?(Integer) && passes >= 0 && passes <= 2**32)
68
+ raise ArgumentError, 'salt must be nil or a String' if salt && !salt.kind_of?(String)
69
+ raise ArgumentError, 'desired_time must be a non-negative Numeric' unless desired_time.kind_of?(Numeric) && desired_time >= 0 && desired_time <= 2**32
70
+
71
+ @type = type
72
+ @memory = memory
73
+ @parallelism = parallelism
74
+ @passes = passes
75
+ @salt = salt
76
+ @desired_time = desired_time
77
+ end
78
+
79
+ # Returns an instance of {Argon2Params} with the actual number of passes
80
+ # and salt used.
81
+ #
82
+ # @param actual_passes [Integer] The number of passes or iterations used.
83
+ # @param actual_salt [String] The actual salt used.
84
+ #
85
+ # @return [Argon2Params] An instance of {Argon2Params} with the given
86
+ # passes and salt.
87
+ #
88
+ # @raise [ArgumentError] If `actual_passes` is not a positive `Integer`.
89
+ # @raise [ArgumentError] If `actual_salt` is not a `String`.
90
+ def complete(passes, salt)
91
+ raise ArgumentError, 'passes must not be nil' unless passes
92
+ raise ArgumentError, 'salt must not be nil' unless salt
93
+ if @passes == passes && @salt == salt
94
+ self
95
+ else
96
+ Argon2Params.new(type: @type, memory: @memory, parallelism: @parallelism, passes: passes, salt: salt, desired_time: @desired_time)
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -18,6 +18,23 @@ module PuTTY
18
18
  class UnsupportedCurveError < Error
19
19
  end
20
20
 
21
+ # Indicates that libargon2 encountered an error hashing the passphrase to
22
+ # derive the keys for a format 3 .ppk file.
23
+ class Argon2Error < Error
24
+ # The error code returned by the `argon2_hash` function.
25
+ attr_reader :error_code
26
+
27
+ # Initializes a new {Argon2Error}.
28
+ #
29
+ # @param error_code [Integer] The error code returned by the `argon2_hash`
30
+ # function.
31
+ # @param message [String] A description of the error.
32
+ def initialize(error_code, message)
33
+ super(message)
34
+ @error_code = error_code
35
+ end
36
+ end
37
+
21
38
  # Indicates that a nil value has been encountered.
22
39
  class NilValueError < Error
23
40
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ffi'
4
+
5
+ module PuTTY
6
+ module Key
7
+ # A wrapper for the required functions from libargon2.
8
+ module Libargon2
9
+ extend ::FFI::Library
10
+
11
+ ffi_lib ['argon2', 'libargon2.so.1', 'libargon2.dll', 'Argon2OptDll.dll', 'Argon2RefDll.dll']
12
+
13
+ # Returned by `argon2_hash` if successful.
14
+ ARGON2_OK = 0
15
+
16
+ # The type of hash to perform.
17
+ enum :argon2_type, [:d, 0, :i, 1, :id, 2]
18
+
19
+ # The version of the algorithm to use.
20
+ enum FFI::Type::UINT32, :argon2_version, [:version_10, 0x10, :version_13, 0x13]
21
+
22
+ # Hashes a password with Argon2, producing a raw hash at hash.
23
+ #
24
+ # t_cost Number of iterations.
25
+ # m_cost Sets memory usage to m_cost kibibytes.
26
+ # parallelism Number of threads and compute lanes.
27
+ # pwd Pointer to password.
28
+ # pwdlen Password size in bytes.
29
+ # salt Pointer to salt.
30
+ # saltlen Salt size in bytes.
31
+ # hash Buffer where to write the raw hash - updated by the function.
32
+ # hashlen Desired length of the hash in bytes.
33
+ #
34
+ # Different parallelism levels will give different results.
35
+ #
36
+ # Returns ARGON2_OK if successful.
37
+ #
38
+ # ARGON2_PUBLIC int argon2_hash(const uint32_t t_cost, const uint32_t m_cost,
39
+ # const uint32_t parallelism, const void *pwd,
40
+ # const size_t pwdlen, const void *salt,
41
+ # const size_t saltlen, void *hash,
42
+ # const size_t hashlen, char *encoded,
43
+ # const size_t encodedlen, argon2_type type,
44
+ # const uint32_t version);
45
+ attach_function 'argon2_hash', [:uint32, :uint32, :uint32, :pointer, :size_t, :pointer, :size_t, :pointer, :size_t, :pointer, :size_t, :argon2_type, :argon2_version], :int
46
+
47
+ # Returns an error message corresponding to the given error code.
48
+ #
49
+ # ARGON2_PUBLIC const char *argon2_error_message(int error_code);
50
+ attach_function :argon2_error_message, [:int], :string
51
+ end
52
+ private_constant :Libargon2
53
+ end
54
+ end
data/lib/putty/key/ppk.rb CHANGED
@@ -7,9 +7,11 @@ module PuTTY
7
7
  # Represents a PuTTY private key (.ppk) file.
8
8
  #
9
9
  # The {PPK} {#initialize constructor} can be used to either create an
10
- # uninitialized key or to load a .ppk file.
10
+ # uninitialized key or to read a .ppk file (from file or an `IO`-like
11
+ # instance).
11
12
  #
12
- # The {#save} method can be used to save a {PPK} instance to a .ppk file.
13
+ # The {#save} method can be used to write a {PPK} instance to a .ppk file or
14
+ # `IO`-like instance.
13
15
  #
14
16
  # The {#algorithm}, {#comment}, {#public_blob} and {#private_blob}
15
17
  # attributes provide access to the high level fields of the PuTTY private
@@ -17,23 +19,45 @@ module PuTTY
17
19
  # based on the algorithm.
18
20
  #
19
21
  # Encrypted .ppk files can be read and written by specifying a passphrase
20
- # when loading or saving. As supported by PuTTY, files are always encrypted
21
- # using AES in CBC mode with a 256-bit key derived from the passphrase using
22
- # SHA-1.
22
+ # when loading or saving. Files are encrypted using AES in CBC mode with a
23
+ # 256-bit key derived from the passphrase.
23
24
  #
24
- # The {PPK} class supports files corresponding to PuTTY's format 2. Format 1
25
- # was only used briefly early on in the development of the .ppk format.
25
+ # The {PPK} class supports files corresponding to PuTTY's formats 2 and 3.
26
+ # Format 1 (not supported) was only used briefly early on in the development
27
+ # of the .ppk format and was never released. Format 2 is supported by PuTTY
28
+ # version 0.52 onwards. Format 3 is supported by PuTTY version 0.75 onwards.
29
+ # {PPK#save} defaults to format 2. Use the `format` parameter to select
30
+ # format 3.
31
+ #
32
+ # libargon2 (https://github.com/P-H-C/phc-winner-argon2) is required to load
33
+ # and save encrypted format 3 files. Binaries are typically available with
34
+ # your OS distribution. For Windows, binaries are available at
35
+ # https://github.com/philr/argon2-windows/releases - use either
36
+ # Argon2OptDll.dll for CPUs supporting AVX or Argon2RefDll.dll otherwise.
26
37
  class PPK
27
- # String used in the computation of the private MAC.
28
- MAC_KEY = 'putty-private-key-file-mac-key'
29
- private_constant :MAC_KEY
38
+ # String used in the computation of the format 3 MAC.
39
+ FORMAT_2_MAC_KEY = 'putty-private-key-file-mac-key'
40
+ private_constant :FORMAT_2_MAC_KEY
41
+
42
+ # Length of the key used for the format 3 MAC.
43
+ FORMAT_3_MAC_KEY_LENGTH = 32
44
+ private_constant :FORMAT_3_MAC_KEY_LENGTH
30
45
 
31
46
  # The default (and only supported) encryption algorithm.
32
47
  DEFAULT_ENCRYPTION_TYPE = 'aes256-cbc'.freeze
33
48
 
34
- # The default (and only supported) PuTTY private key file format.
49
+ # The default PuTTY private key file format.
35
50
  DEFAULT_FORMAT = 2
36
51
 
52
+ # The mimimum supported PuTTY private key file format.
53
+ MINIMUM_FORMAT = 2
54
+
55
+ # The maximum supported PuTTY private key file format.
56
+ MAXIMUM_FORMAT = 3
57
+
58
+ # Default Argon2 key derivation parameters for use with format 3.
59
+ DEFAULT_ARGON2_PARAMS = Argon2Params.new.freeze
60
+
37
61
  # The key's algorithm, for example, 'ssh-rsa' or 'ssh-dss'.
38
62
  #
39
63
  # @return [String] The key's algorithm, for example, 'ssh-rsa' or
@@ -59,70 +83,116 @@ module PuTTY
59
83
  # @return [String] The private component of the key
60
84
  attr_accessor :private_blob
61
85
 
62
- # Constructs a new {PPK} instance either uninitialized, or by loading a
63
- # .ppk file.
86
+ # Constructs a new {PPK} instance either uninitialized, or initialized
87
+ # by reading from a .ppk file or an `IO`-like instance.
88
+ #
89
+ # To read from a file set `path_or_io` to the file path, either as a
90
+ # `String` or a `Pathname`. To read from an `IO`-like instance set
91
+ # `path_or_io` to the instance. The instance must respond to `#read`.
92
+ # `#binmode` will be called before reading if supported by the instance.
64
93
  #
65
- # @param path [Object] Set to the path of a .ppk file to load the file.
66
- # Leave as `nil` to leave the new {PPK} instance uninitialized.
94
+ # @param path_or_io [Object] Set to the path of a .ppk file to load the
95
+ # file as a `String` or `Pathname`, or an `IO`-like instance to read the
96
+ # .ppk file from that instance. Set to `nil` to leave the new {PPK}
97
+ # instance uninitialized.
67
98
  # @param passphrase [String] The passphrase to use when loading an
68
99
  # encrypted .ppk file.
69
100
  #
70
101
  # @raise [Errno::ENOENT] If the file specified by `path` does not exist.
71
102
  # @raise [ArgumentError] If the .ppk file was encrypted, but either no
72
103
  # passphrase or an incorrect passphrase was supplied.
73
- # @raise [FormatError] If the .ppk file is malformed.
74
- def initialize(path = nil, passphrase = nil)
104
+ # @raise [FormatError] If the .ppk file is malformed or not supported.
105
+ # @raise [LoadError] If opening an encrypted format 3 .ppk file and
106
+ # libargon2 could not be loaded.
107
+ # @raise [Argon2Error] If opening an encrypted format 3 .ppk file and
108
+ # libargon2 reported an error hashing the passphrase.
109
+ def initialize(path_or_io = nil, passphrase = nil)
75
110
  passphrase = nil if passphrase && passphrase.to_s.empty?
76
111
 
77
- if path
78
- encryption_type, encrypted_private_blob, private_mac = Reader.open(path) do |reader|
79
- @algorithm = reader.field('PuTTY-User-Key-File-2')
112
+ if path_or_io
113
+ Reader.open(path_or_io) do |reader|
114
+ format, @algorithm = reader.field_matching(/PuTTY-User-Key-File-(\d+)/)
115
+ format = format.to_i
116
+ raise FormatError, "The ppk file is using a format that is too new (#{format})" if format > MAXIMUM_FORMAT
117
+ raise FormatError, "The ppk file is using an old unsupported format (#{format})" if format < MINIMUM_FORMAT
118
+
80
119
  encryption_type = reader.field('Encryption')
81
120
  @comment = reader.field('Comment')
82
121
  @public_blob = reader.blob('Public')
83
- encrypted_private_blob = reader.blob('Private')
84
- private_mac = reader.field('Private-MAC')
85
- [encryption_type, encrypted_private_blob, private_mac]
86
- end
87
122
 
88
- if encryption_type == 'none'
89
- passphrase = nil
90
- @private_blob = encrypted_private_blob
91
- else
92
- raise FormatError, "The ppk file is encrypted with #{encryption_type}, which is not supported" unless encryption_type == DEFAULT_ENCRYPTION_TYPE
93
- raise ArgumentError, 'The ppk file is encrypted, a passphrase must be supplied' unless passphrase
94
-
95
- # PuTTY uses a zero IV.
96
- cipher = ::OpenSSL::Cipher::AES.new(256, :CBC)
97
- cipher.decrypt
98
- cipher.key = generate_encryption_key(passphrase, cipher.key_len)
99
- cipher.padding = 0
100
- @private_blob = cipher.update(encrypted_private_blob) + cipher.final
101
- end
102
123
 
103
- expected_private_mac = compute_private_mac(passphrase, encryption_type, @private_blob)
124
+ if encryption_type == 'none'
125
+ passphrase = nil
126
+ mac_key = derive_keys(format).first
127
+ @private_blob = reader.blob('Private')
128
+ else
129
+ raise FormatError, "The ppk file is encrypted with #{encryption_type}, which is not supported" unless encryption_type == DEFAULT_ENCRYPTION_TYPE
130
+ raise ArgumentError, 'The ppk file is encrypted, a passphrase must be supplied' unless passphrase
131
+
132
+ argon2_params = if format >= 3
133
+ type = get_argon2_type(reader.field('Key-Derivation'))
134
+ memory = reader.unsigned_integer('Argon2-Memory', maximum: 2**32)
135
+ passes = reader.unsigned_integer('Argon2-Passes', maximum: 2**32)
136
+ parallelism = reader.unsigned_integer('Argon2-Parallelism', maximum: 2**32)
137
+ salt = reader.field('Argon2-Salt')
138
+ unless salt =~ /\A(?:[0-9a-fA-F]{2})+\z/
139
+ raise FormatError, "Expected the Argon2-Salt field to be a hex string, but found #{salt}"
140
+ end
141
+
142
+ Argon2Params.new(type: type, memory: memory, passes: passes, parallelism: parallelism, salt: [salt].pack('H*'))
143
+ end
144
+
145
+ cipher = ::OpenSSL::Cipher::AES.new(256, :CBC)
146
+ cipher.decrypt
147
+ mac_key, cipher.key, cipher.iv = derive_keys(format, cipher, passphrase, argon2_params)
148
+ cipher.padding = 0
149
+ encrypted_private_blob = reader.blob('Private')
150
+
151
+ @private_blob = if encrypted_private_blob.bytesize > 0
152
+ partial = cipher.update(encrypted_private_blob)
153
+ final = cipher.final
154
+ partial + final
155
+ else
156
+ encrypted_private_blob
157
+ end
158
+ end
104
159
 
105
- unless private_mac == expected_private_mac
106
- raise ArgumentError, 'Incorrect passphrase supplied' if passphrase
107
- raise FormatError, "Invalid Private MAC (expected #{expected_private_mac}, but found #{private_mac})"
160
+ private_mac = reader.field('Private-MAC')
161
+ expected_private_mac = compute_private_mac(format, mac_key, encryption_type, @private_blob)
162
+
163
+ unless private_mac == expected_private_mac
164
+ raise ArgumentError, 'Incorrect passphrase supplied' if passphrase
165
+ raise FormatError, "Invalid Private MAC (expected #{expected_private_mac}, but found #{private_mac})"
166
+ end
108
167
  end
109
168
  end
110
169
  end
111
170
 
112
- # Saves this PuTTY private key instance to a .ppk file.
171
+ # Writes this PuTTY private key instance to a .ppk file or `IO`-like
172
+ # instance.
173
+ #
174
+ # To write to a file, set `path_or_io` to the file path, either as a
175
+ # `String` or a `Pathname`. To write to an `IO`-like instance set
176
+ # `path_or_io` to the instance. The instance must respond to `#write`.
177
+ # `#binmode` will be called before writing if supported by the instance.
178
+ #
179
+ # If a file with the given path already exists, it will be overwritten.
113
180
  #
114
181
  # The {#algorithm}, {#private_blob} and {#public_blob} attributes must
115
182
  # have been set before calling {#save}.
116
183
  #
117
- # @param path [Object] The path to write to. If a file already exists, it
118
- # will be overwritten.
184
+ # @param path_or_io [Object] The path to write to as a `String` or
185
+ # `Pathname`, or an `IO`-like instance to write to.
119
186
  # @param passphrase [String] Set `passphrase` to encrypt the .ppk file
120
187
  # using the specified passphrase. Leave as `nil` to create an
121
188
  # unencrypted .ppk file.
122
189
  # @param encryption_type [String] The encryption algorithm to use.
123
190
  # Defaults to and currently only supports `'aes256-cbc'`.
124
191
  # @param format [Integer] The format of .ppk file to create. Defaults to
125
- # and currently only supports `2`.
192
+ # `2`. Supports `2` and `3`.
193
+ # @param argon2_params [Argon2Params] The parameters to use with Argon2
194
+ # to derive the encryption key, initialization vector and MAC key when
195
+ # saving an encrypted format 3 .ppk file.
126
196
  #
127
197
  # @return [Integer] The number of bytes written to the file.
128
198
  #
@@ -131,52 +201,73 @@ module PuTTY
131
201
  # @raise [ArgumentError] If `path` is nil.
132
202
  # @raise [ArgumentError] If a passphrase has been specified and
133
203
  # `encryption_type` is not `'aes256-cbc'`.
134
- # @raise [ArgumentError] If `format` is not `2`.
204
+ # @raise [ArgumentError] If `format` is not `2` or `3`.
205
+ # @raise [ArgumentError] If `argon2_params` is `nil`, a passphrase has
206
+ # been specified and `format` is `3`.
135
207
  # @raise [Errno::ENOENT] If a directory specified by `path` does not
136
208
  # exist.
137
- def save(path, passphrase = nil, encryption_type: DEFAULT_ENCRYPTION_TYPE, format: DEFAULT_FORMAT)
209
+ # @raise [LoadError] If saving an encrypted format 3 .ppk file and
210
+ # libargon2 could not be loaded.
211
+ # @raise [Argon2Error] If saving an encrypted format 3 .ppk file and
212
+ # libargon2 reported an error hashing the passphrase.
213
+ def save(path_or_io, passphrase = nil, encryption_type: DEFAULT_ENCRYPTION_TYPE, format: DEFAULT_FORMAT, argon2_params: DEFAULT_ARGON2_PARAMS)
138
214
  raise InvalidStateError, 'algorithm must be set before calling save' unless @algorithm
139
215
  raise InvalidStateError, 'public_blob must be set before calling save' unless @public_blob
140
216
  raise InvalidStateError, 'private_blob must be set before calling save' unless @private_blob
141
217
 
218
+ raise ArgumentError, 'An output path or io instance must be specified' unless path_or_io
219
+
142
220
  passphrase = nil if passphrase && passphrase.to_s.empty?
143
- encryption_type = 'none' unless passphrase
144
221
 
145
- raise ArgumentError, 'An output path must be specified' unless path
222
+ raise ArgumentError, 'A format must be specified' unless format
223
+ raise ArgumentError, "Unsupported format: #{format}" unless format >= MINIMUM_FORMAT && format <= MAXIMUM_FORMAT
146
224
 
147
225
  if passphrase
148
226
  raise ArgumentError, 'An encryption_type must be specified if a passphrase is specified' unless encryption_type
149
227
  raise ArgumentError, "Unsupported encryption_type: #{encryption_type}" unless encryption_type == DEFAULT_ENCRYPTION_TYPE
150
- end
151
-
152
- raise ArgumentError, 'A format must be specified' unless format
153
- raise ArgumentError, "Unsupported format: #{format}" unless format == DEFAULT_FORMAT
154
-
155
- padded_private_blob = @private_blob
228
+ raise ArgumentError, 'argon2_params must be specified if a passphrase is specified with format 3' unless format < 3 || argon2_params
156
229
 
157
- if passphrase
158
- # Pad using an SHA-1 hash of the unpadded private blob in order to
159
- # prevent an easily known plaintext attack on the last block.
160
230
  cipher = ::OpenSSL::Cipher::AES.new(256, :CBC)
161
231
  cipher.encrypt
162
- padding_length = cipher.block_size - (padded_private_blob.bytesize % cipher.block_size)
163
- padded_private_blob += ::OpenSSL::Digest::SHA1.new(padded_private_blob).digest[0, padding_length]
164
-
165
- # PuTTY uses a zero IV.
166
- cipher.key = generate_encryption_key(passphrase, cipher.key_len)
232
+ mac_key, cipher.key, cipher.iv, kdf_params = derive_keys(format, cipher, passphrase, argon2_params)
167
233
  cipher.padding = 0
168
- encrypted_private_blob = cipher.update(padded_private_blob) + cipher.final
234
+
235
+ # Pad using an SHA-1 hash of the unpadded private blob in order to
236
+ # prevent an easily known plaintext attack on the last block.
237
+ padding_length = cipher.block_size - ((@private_blob.bytesize - 1) % cipher.block_size) - 1
238
+ padded_private_blob = @private_blob
239
+ padded_private_blob += ::OpenSSL::Digest::SHA1.new(@private_blob).digest.byteslice(0, padding_length) if padding_length > 0
240
+
241
+ encrypted_private_blob = if padded_private_blob.bytesize > 0
242
+ partial = cipher.update(padded_private_blob)
243
+ final = cipher.final
244
+ partial + final
245
+ else
246
+ padded_private_blob
247
+ end
169
248
  else
170
- encrypted_private_blob = private_blob
249
+ encryption_type = 'none'
250
+ mac_key = derive_keys(format).first
251
+ kdf_params = nil
252
+ padded_private_blob = @private_blob
253
+ encrypted_private_blob = padded_private_blob
171
254
  end
172
255
 
173
- private_mac = compute_private_mac(passphrase, encryption_type, padded_private_blob)
256
+ private_mac = compute_private_mac(format, mac_key, encryption_type, padded_private_blob)
174
257
 
175
- Writer.open(path) do |writer|
176
- writer.field('PuTTY-User-Key-File-2', @algorithm)
258
+ Writer.open(path_or_io) do |writer|
259
+ writer.field("PuTTY-User-Key-File-#{format}", @algorithm)
177
260
  writer.field('Encryption', encryption_type)
178
261
  writer.field('Comment', @comment)
179
262
  writer.blob('Public', @public_blob)
263
+ if kdf_params
264
+ # Only Argon2 is currently supported.
265
+ writer.field('Key-Derivation', "Argon2#{kdf_params.type}")
266
+ writer.field('Argon2-Memory', kdf_params.memory)
267
+ writer.field('Argon2-Passes', kdf_params.passes)
268
+ writer.field('Argon2-Parallelism', kdf_params.parallelism)
269
+ writer.field('Argon2-Salt', kdf_params.salt.unpack('H*').first)
270
+ end
180
271
  writer.blob('Private', encrypted_private_blob)
181
272
  writer.field('Private-MAC', private_mac)
182
273
  end
@@ -184,13 +275,176 @@ module PuTTY
184
275
 
185
276
  private
186
277
 
187
- # Generates an encryption key of the specified length from a passphrase.
278
+ # Returns the Argon2 type (`:d`, `:i` or `:id`) corresponding to the value
279
+ # of the Key-Derivation field in the .ppk file.
280
+ #
281
+ # @param key_derivation [String] The value of the Key-Derivation field.
282
+ #
283
+ # @return [Symbol] The Argon2 type.
284
+ #
285
+ # @raise [FormatError] If `key_derivation` is unrecognized.
286
+ def get_argon2_type(key_derivation)
287
+ unless key_derivation =~ /\AArgon2(d|id?)\z/
288
+ raise FormatError, "Unrecognized key derivation type: #{key_derivation}"
289
+ end
290
+
291
+ $1.to_sym
292
+ end
293
+
294
+ # Derives the MAC key, encryption key and initialization vector from the
295
+ # passphrase (if the file is encrypted).
296
+ #
297
+ # @param format [Integer] The format of the .ppk file.
298
+ # @param cipher [OpenSSL::Cipher] The cipher being used to encrypt or
299
+ # decrypt the .ppk file or `nil` if not encrypted.
300
+ # @param passphrase [String] The passphrase used in the derivation or
301
+ # `nil` if the .ppk file is not encrypted. The raw bytes of the
302
+ # passphrase are used in the derivation.
303
+ # @param argon2_params [Argon2Params] Parameters used with the Argon2 hash
304
+ # function. May be `nil` if the .ppk file is not encrypted or `format`
305
+ # is less than 3.
306
+ #
307
+ # @return [Array<String, String, String, Argon2Params>] The MAC key,
308
+ # encryption key, initialization vector and final Argon2 parameters.
309
+ # The encryption key and initialization vector will be `nil` if `cipher`
310
+ # is `nil`. The final Argon2 parameters will only be set if `format` is
311
+ # greater than or equal to 3 and `cipher` is not nil. The final Argon2
312
+ # parameters will differ from `argon2_params` if the salt and passes
313
+ # options were left unspecified.
314
+ #
315
+ # @raise [LoadError] If `format` is at least 3, `cipher` is specified and
316
+ # libargon2 could not be loaded.
317
+ # @raise [Argon2Error] If `format` is at least 3, `cipher` is specified
318
+ # and libargon2 reported an error hashing the passphrase.
319
+ def derive_keys(format, cipher = nil, passphrase = nil, argon2_params = nil)
320
+ if format >= 3
321
+ return derive_format_3_keys(cipher, passphrase, argon2_params) if cipher
322
+ return [''.b, nil, nil, nil]
323
+ end
324
+
325
+ mac_key = derive_format_2_mac_key(passphrase)
326
+
327
+ if cipher
328
+ key = derive_format_2_encryption_key(passphrase, cipher.key_len)
329
+ iv = "\0".b * cipher.iv_len
330
+ else
331
+ key = nil
332
+ iv = nil
333
+ end
334
+
335
+ [mac_key, key, iv, nil]
336
+ end
337
+
338
+ # Initializes the Argon2 salt if required, determines the number of passes
339
+ # to use to meet the time requirement unless preset and then derives the
340
+ # MAC key, encryption key and initalization vector.
341
+ #
342
+ # @param cipher [OpenSSL::Cipher] The cipher being used to encrypt or
343
+ # decrypt the .ppk file.
344
+ # @param passphrase [String] The passphrase used in the derivation. The
345
+ # raw bytes of the passphrase are used in the derivation.
346
+ # @param argon2_params [Argon2Params] Parameters used with the Argon2 hash
347
+ # function.
348
+ #
349
+ # @return [Array<String, String, String, Argon2Params>] The MAC key,
350
+ # encryption key, initialization vector and final Argon2 parameters.
351
+ # The encryption key and initialization vector will be `nil` if `cipher`
352
+ # is `nil`. The final Argon2 parameters will differ from `argon2_params`
353
+ # if the salt and passes options were left unspecified.
354
+ #
355
+ # @raise [LoadError] If libargon2 could not be loaded.
356
+ # @raise [Argon2Error] If libargon2 reported an error hashing the
357
+ # passphrase.
358
+ def derive_format_3_keys(cipher, passphrase, argon2_params)
359
+ # Defer loading of libargon2 to avoid a mandatory dependency.
360
+ require_relative 'libargon2'
361
+
362
+ salt = argon2_params.salt || ::OpenSSL::Random.random_bytes(16)
363
+ passphrase_ptr = pointer_for_bytes(passphrase)
364
+ salt_ptr = pointer_for_bytes(salt)
365
+ hash_ptr = FFI::MemoryPointer.new(:char, cipher.key_len + cipher.iv_len + FORMAT_3_MAC_KEY_LENGTH)
366
+ begin
367
+ passes = argon2_params.passes
368
+ if passes
369
+ argon2_hash(argon2_params.type, argon2_params.passes, argon2_params.memory, argon2_params.parallelism, passphrase_ptr, salt_ptr, hash_ptr)
370
+ else
371
+ # Only require the time taken to be approximately correct. Scale up
372
+ # geometrically using Fibonacci numbers (as per PuTTY's
373
+ # implementation).
374
+ prev_passes = 1
375
+ passes = 1
376
+
377
+ loop do
378
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
379
+ argon2_hash(argon2_params.type, passes, argon2_params.memory, argon2_params.parallelism, passphrase_ptr, salt_ptr, hash_ptr)
380
+ elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000
381
+ break if (elapsed >= argon2_params.desired_time)
382
+ hash_ptr.clear
383
+ new_passes = passes + prev_passes
384
+ break if new_passes > 2**32 # maximum allowed by argon2_hash parameter data type
385
+ prev_passes, passes = passes, new_passes
386
+ end
387
+ end
388
+
389
+ passphrase_ptr.clear
390
+ key = hash_ptr.get_bytes(0, cipher.key_len)
391
+ iv = hash_ptr.get_bytes(cipher.key_len, cipher.iv_len)
392
+ mac_key = hash_ptr.get_bytes(cipher.key_len + cipher.iv_len, FORMAT_3_MAC_KEY_LENGTH)
393
+ argon2_params = argon2_params.complete(passes, salt)
394
+ hash_ptr.clear
395
+ [mac_key, key, iv, argon2_params]
396
+ ensure
397
+ # Calling free isn't actually required, but this releases the memory
398
+ # sooner.
399
+ hash_ptr.free
400
+ salt_ptr.free
401
+ passphrase_ptr.free
402
+ end
403
+ end
404
+
405
+ # Creates an `FFI::MemoryPointer` containing the raw bytes from `string`
406
+ # without a null terminator.
407
+ #
408
+ # @param string [String] The bytes to use for the `FFI::MemoryPointer`.
409
+ #
410
+ # @return [FFI::MemoryPointer] A new `FFI::MemoryPointer` containing the
411
+ # raw bytes from `string`.
412
+ def pointer_for_bytes(string)
413
+ FFI::MemoryPointer.new(:char, string.bytesize).tap do |ptr|
414
+ ptr.put_bytes(0, string)
415
+ end
416
+ end
417
+
418
+ # Calls the libargon2 `argon2_hash` function to obtain a raw hash using
419
+ # version 13 of the algorithm.
420
+ #
421
+ # @param type [Symbol] The variant of Argon2 to use. (`:d`, `:i` or
422
+ # `:id`).
423
+ # @param iterations [Integer] The number of iterations to use.
424
+ # @param memory [Integer] Memory usage in kibibytes.
425
+ # @param passhrase [FFI::MemoryPointer] The passphrase.
426
+ # @param salt [FFI::MemoryPointer] The salt.
427
+ # @param hash [FFI::MemoryPointer] A buffer to write the raw hash to.
428
+ #
429
+ # @raise [Argon2Error] If `argon2_hash` returns an error.
430
+ def argon2_hash(type, iterations, memory, parallelism, passphrase, salt, hash)
431
+ res = Libargon2.argon2_hash(iterations, memory, parallelism,
432
+ passphrase, passphrase.size, salt, salt.size,
433
+ hash, hash.size, FFI::Pointer::NULL, 0, type, :version_13)
434
+
435
+ unless res == Libargon2::ARGON2_OK
436
+ raise Argon2Error.new(res, Libargon2.argon2_error_message(res))
437
+ end
438
+ end
439
+
440
+ # Derives an encryption key of the specified length from a passphrase for
441
+ # use in format 2 files.
188
442
  #
189
443
  # @param passphrase [String] The passphrase to use.
190
444
  # @param key_length [Integer] The length of the desired key in bytes.
191
445
  #
192
- # @return [String] The generated key.
193
- def generate_encryption_key(passphrase, key_length)
446
+ # @return [String] The derived encryption key.
447
+ def derive_format_2_encryption_key(passphrase, key_length)
194
448
  key = String.new
195
449
  key_digest = ::OpenSSL::Digest::SHA1.new
196
450
  iteration = 0
@@ -209,47 +463,72 @@ module PuTTY
209
463
  key[0, key_length]
210
464
  end
211
465
 
466
+ # Derives a MAC key from a passphrase for use in format 2 files.
467
+ #
468
+ # @param passphrase [String] The passphrase to use or `nil` if not
469
+ # encrypted.
470
+ #
471
+ # @return [String] The derived MAC key.
472
+ def derive_format_2_mac_key(passphrase)
473
+ key = ::OpenSSL::Digest::SHA1.new
474
+ key.update(FORMAT_2_MAC_KEY)
475
+ key.update(passphrase) if passphrase
476
+ key.digest
477
+ end
478
+
212
479
  # Computes the value of the Private-MAC field given the passphrase,
213
480
  # encryption type and padded private blob (the value of the private blob
214
481
  # after padding bytes have been appended prior to encryption).
215
482
  #
483
+ # @param format [Integer] The format of the .ppk file.
216
484
  # @param passphrase [String] The encryption passphrase.
217
485
  # @param encryption_type [String] The value of the Encryption field.
218
486
  # @param padded_private_blob [String] The private blob after padding bytes
219
487
  # have been appended prior to encryption.
220
488
  #
221
489
  # @return [String] The computed private MAC.
222
- def compute_private_mac(passphrase, encryption_type, padded_private_blob)
223
- key = ::OpenSSL::Digest::SHA1.new
224
- key.update(MAC_KEY)
225
- key.update(passphrase) if passphrase
490
+ def compute_private_mac(format, mac_key, encryption_type, padded_private_blob)
491
+ digest = format <= 2 ? ::OpenSSL::Digest::SHA1 : ::OpenSSL::Digest::SHA256
226
492
  data = Util.ssh_pack(@algorithm, encryption_type, @comment || '', @public_blob, padded_private_blob)
227
- ::OpenSSL::HMAC.hexdigest(::OpenSSL::Digest::SHA1.new, key.digest, data)
493
+ ::OpenSSL::HMAC.hexdigest(digest.new, mac_key, data)
228
494
  end
229
495
 
230
496
  # Handles reading .ppk files.
231
497
  #
232
498
  # @private
233
499
  class Reader
234
- # Opens a .ppk file for reading, creates a new instance of `Reader` and
235
- # yields it to the caller.
500
+ # Opens a .ppk file for reading (or uses the provided `IO`-like
501
+ # instance), creates a new instance of `Reader` and yields it to the
502
+ # caller.
236
503
  #
237
- # @param path [Object] The path of the .ppk file to be read.
504
+ # @param path_or_io [Object] The path of the .ppk file to be read or an
505
+ # `IO`-like object.
238
506
  #
239
507
  # @return [Object] The result of yielding to the caller.
240
508
  #
241
509
  # raise [Errno::ENOENT] If the file specified by `path` does not exist.
242
- def self.open(path)
243
- File.open(path.to_s, 'rb') do |file|
244
- yield Reader.new(file)
510
+ def self.open(path_or_io)
511
+ if path_or_io.kind_of?(String) || path_or_io.kind_of?(Pathname)
512
+ File.open(path_or_io.to_s, 'rb') do |file|
513
+ yield Reader.new(file)
514
+ end
515
+ else
516
+ path_or_io.binmode if path_or_io.respond_to?(:binmode)
517
+
518
+ unless path_or_io.respond_to?(:getbyte)
519
+ path_or_io = GetbyteIo.new(path_or_io)
520
+ end
521
+
522
+ yield Reader.new(path_or_io)
245
523
  end
246
524
  end
247
525
 
248
- # Initializes a new {Reader} with an {IO} to read from.
526
+ # Initializes a new {Reader} with an `IO`-like instance to read from.
249
527
  #
250
- # @param file [IO] The file to read from.
528
+ # @param file [Object] The `IO`-like instance to read from.
251
529
  def initialize(file)
252
530
  @file = file
531
+ @consumed_byte = nil
253
532
  end
254
533
 
255
534
  # Reads the next field from the file.
@@ -266,6 +545,46 @@ module PuTTY
266
545
  line.byteslice(name.bytesize + 2, line.bytesize - name.bytesize - 2)
267
546
  end
268
547
 
548
+ # Reads the next field from the file.
549
+ #
550
+ # @param name_regexp [Regexp] A `Regexp` that matches the expected field
551
+ # name.
552
+ #
553
+ # @return [String] The value of the field if the regular expression has
554
+ # no captures.
555
+ # @return [Array] An array containing the regular expression captures as
556
+ # the first elements and the value of the field as the last element.
557
+ #
558
+ # @raise [FormatError] If the current position in the file was not the
559
+ # start of a field with the expected name.
560
+ def field_matching(name_regexp)
561
+ line = read_line
562
+ line_regexp = Regexp.new("\\A#{name_regexp.source}: ", name_regexp.options)
563
+ match = line_regexp.match(line)
564
+ raise FormatError, "Expected field matching #{name_regexp}, but found #{line}" unless match
565
+ prefix = match[0]
566
+ value = line.byteslice(prefix.bytesize, line.bytesize - prefix.bytesize)
567
+ captures = match.captures
568
+ captures.empty? ? value : captures + [value]
569
+ end
570
+
571
+ # Reads the next field from the file as an unsigned integer.
572
+ #
573
+ # @param name [String] The expected field name.
574
+ #
575
+ # @return [Integer] The value of the field.
576
+ #
577
+ # @raise [FormatError] If the current position in the file was not the
578
+ # start of a field with the expected name.
579
+ # @raise [FormatError] If the field did not contain a positive integer.
580
+ def unsigned_integer(name, maximum: nil)
581
+ value = field(name)
582
+ value = value =~ /\A[0-9]+\z/ && value.to_i
583
+ raise FormatError, "Expected field #{name} to contain an unsigned integer value, but found #{value}" unless value
584
+ raise FormatError, "Expected field #{name} to have a maximum of #{maximum}, but found #{value}" if maximum && value > maximum
585
+ value
586
+ end
587
+
269
588
  # Reads a blob from the file consisting of a Lines field whose value
270
589
  # gives the number of Base64 encoded lines in the blob.
271
590
  #
@@ -276,28 +595,68 @@ module PuTTY
276
595
  # @raise [FormatError] If the value of the Lines field is not a
277
596
  # positive integer.
278
597
  def blob(name)
279
- lines = field("#{name}-Lines")
280
- raise FormatError, "Invalid value for #{name}-Lines" unless lines =~ /\A\d+\z/
281
- lines.to_i.times.map { read_line }.join("\n").unpack('m48').first
598
+ lines = unsigned_integer("#{name}-Lines")
599
+ lines.times.map { read_line }.join("\n").unpack('m48').first
282
600
  end
283
601
 
284
602
  private
285
603
 
286
- # Reads a single new-line (\n or \r\n) terminated line from the file,
287
- # removing the new-line character.
604
+ # Reads a single new-line (\n, \r\n or \r) terminated line from the
605
+ # file, removing the new-line character.
288
606
  #
289
607
  # @return [String] The line.
290
608
  #
291
609
  # @raise [FormatError] If the end of file was detected before reading a
292
610
  # line.
293
611
  def read_line
294
- @file.readline("\n").chomp("\n")
295
- rescue EOFError
612
+ line = ''.b
613
+
614
+ if @consumed_byte
615
+ line << @consumed_byte
616
+ @consumed_byte = nil
617
+ end
618
+
619
+ while byte = @file.getbyte
620
+ return line if byte == 0x0a
621
+
622
+ if byte == 0x0d
623
+ byte = @file.getbyte
624
+ return line if !byte || byte == 0x0a
625
+ @consumed_byte = byte
626
+ return line
627
+ end
628
+
629
+ line << byte
630
+ end
631
+
632
+ return line if line.bytesize > 0
296
633
  raise FormatError, 'Truncated ppk file detected'
297
634
  end
298
635
  end
299
636
  private_constant :Reader
300
637
 
638
+ # Wraps an `IO`-like instance, providing an implementation of `#getbyte`.
639
+ # Allows reading from `IO`-like instances that only provide `#read`.
640
+ class GetbyteIo
641
+ # Initializes a new {GetbyteIO} with the given `IO`-like instance.
642
+ #
643
+ # @param io [Object] An `IO`-like instance.
644
+ def initialize(io)
645
+ @io = io
646
+ @outbuf = ' '.b
647
+ end
648
+
649
+ # Gets the next 8-bit byte (0..255) from the `IO`-like instance.
650
+ #
651
+ # @return [Integer] the next byte or `nil` if the end of the stream has
652
+ # been reached.
653
+ def getbyte
654
+ s = @io.read(1, @outbuf)
655
+ s && s.getbyte(0)
656
+ end
657
+ end
658
+ private_constant :GetbyteIo
659
+
301
660
  # Handles writing .ppk files.
302
661
  #
303
662
  # @private
@@ -307,24 +666,31 @@ module PuTTY
307
666
  # @return [Integer] The number of bytes that have been written.
308
667
  attr_reader :bytes_written
309
668
 
310
- # Opens a .ppk file for writing, creates a new instance of `Writer` and
311
- # yields it to the caller.
669
+ # Opens a .ppk file for writing (or uses the provided `IO`-like
670
+ # instance), creates a new instance of `Writer` and yields it to the
671
+ # caller.
312
672
  #
313
- # @param path [Object] The path of the .ppk file to be written.
673
+ # @param path_or_io [Object] The path of the .ppk file to be written or
674
+ # an `IO`-like instance.
314
675
  #
315
676
  # @return [Object] The result of yielding to the caller.
316
677
  #
317
678
  # @raise [Errno::ENOENT] If a directory specified by `path` does not
318
679
  # exist.
319
- def self.open(path)
320
- File.open(path.to_s, 'wb') do |file|
321
- yield Writer.new(file)
680
+ def self.open(path_or_io)
681
+ if path_or_io.kind_of?(String) || path_or_io.kind_of?(Pathname)
682
+ File.open(path_or_io.to_s, 'wb') do |file|
683
+ yield Writer.new(file)
684
+ end
685
+ else
686
+ path_or_io.binmode if path_or_io.respond_to?(:binmode)
687
+ yield Writer.new(path_or_io)
322
688
  end
323
689
  end
324
690
 
325
- # Initializes a new {Writer} with an {IO} to write to.
691
+ # Initializes a new {Writer} with an `IO`-like instance to write to.
326
692
  #
327
- # @param file [IO] The file to write to.
693
+ # @param file [Object] The `IO`-like instance to write to.
328
694
  def initialize(file)
329
695
  @file = file
330
696
  @bytes_written = 0
@@ -333,7 +699,7 @@ module PuTTY
333
699
  # Writes a field to the file.
334
700
  #
335
701
  # @param name [String] The field name.
336
- # @param value [String] The field value.
702
+ # @param value [Object] The field value.
337
703
  def field(name, value)
338
704
  write(name)
339
705
  write(': ')
@@ -358,9 +724,9 @@ module PuTTY
358
724
 
359
725
  private
360
726
 
361
- # Writes a line separator to the file (\r\n on all platforms).
727
+ # Writes a line separator to the file (\n on all platforms).
362
728
  def write_line
363
- write("\r\n")
729
+ write("\n")
364
730
  end
365
731
 
366
732
  # Writes a string to the file.