putty-key 1.0.1 → 1.1.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +2 -1
  3. data/CHANGES.md +23 -0
  4. data/Gemfile +10 -5
  5. data/LICENSE +1 -1
  6. data/README.md +32 -6
  7. data/Rakefile +24 -0
  8. data/lib/putty/key/argon2_params.rb +101 -0
  9. data/lib/putty/key/error.rb +17 -0
  10. data/lib/putty/key/libargon2.rb +54 -0
  11. data/lib/putty/key/openssl.rb +279 -48
  12. data/lib/putty/key/ppk.rb +482 -104
  13. data/lib/putty/key/util.rb +10 -10
  14. data/lib/putty/key/version.rb +1 -1
  15. data/lib/putty/key.rb +6 -6
  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. data/test/test_helper.rb +10 -3
  68. data.tar.gz.sig +0 -0
  69. metadata +73 -23
  70. metadata.gz.sig +0 -0
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,188 @@ 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
+
323
+ # An empty string should work for the MAC, but ::OpenSSL::HMAC fails
324
+ # when used with OpenSSL 3:
325
+ #
326
+ # EVP_PKEY_new_mac_key: malloc failure (OpenSSL::HMACError).
327
+ #
328
+ # See https://github.com/ruby/openssl/pull/538 and
329
+ # https://github.com/openssl/openssl/issues/13089.
330
+ #
331
+ # Ruby 3.1.3 should contain the workaround from ruby/openssl PR 538.
332
+ #
333
+ # Use "\0" as the MAC key for a workaround for Ruby < 3.1.3.
334
+ return ["\0".b, nil, nil, nil]
335
+ end
336
+
337
+ mac_key = derive_format_2_mac_key(passphrase)
338
+
339
+ if cipher
340
+ key = derive_format_2_encryption_key(passphrase, cipher.key_len)
341
+ iv = "\0".b * cipher.iv_len
342
+ else
343
+ key = nil
344
+ iv = nil
345
+ end
346
+
347
+ [mac_key, key, iv, nil]
348
+ end
349
+
350
+ # Initializes the Argon2 salt if required, determines the number of passes
351
+ # to use to meet the time requirement unless preset and then derives the
352
+ # MAC key, encryption key and initalization vector.
353
+ #
354
+ # @param cipher [OpenSSL::Cipher] The cipher being used to encrypt or
355
+ # decrypt the .ppk file.
356
+ # @param passphrase [String] The passphrase used in the derivation. The
357
+ # raw bytes of the passphrase are used in the derivation.
358
+ # @param argon2_params [Argon2Params] Parameters used with the Argon2 hash
359
+ # function.
360
+ #
361
+ # @return [Array<String, String, String, Argon2Params>] The MAC key,
362
+ # encryption key, initialization vector and final Argon2 parameters.
363
+ # The encryption key and initialization vector will be `nil` if `cipher`
364
+ # is `nil`. The final Argon2 parameters will differ from `argon2_params`
365
+ # if the salt and passes options were left unspecified.
366
+ #
367
+ # @raise [LoadError] If libargon2 could not be loaded.
368
+ # @raise [Argon2Error] If libargon2 reported an error hashing the
369
+ # passphrase.
370
+ def derive_format_3_keys(cipher, passphrase, argon2_params)
371
+ # Defer loading of libargon2 to avoid a mandatory dependency.
372
+ require_relative 'libargon2'
373
+
374
+ salt = argon2_params.salt || ::OpenSSL::Random.random_bytes(16)
375
+ passphrase_ptr = pointer_for_bytes(passphrase)
376
+ salt_ptr = pointer_for_bytes(salt)
377
+ hash_ptr = FFI::MemoryPointer.new(:char, cipher.key_len + cipher.iv_len + FORMAT_3_MAC_KEY_LENGTH)
378
+ begin
379
+ passes = argon2_params.passes
380
+ if passes
381
+ argon2_hash(argon2_params.type, argon2_params.passes, argon2_params.memory, argon2_params.parallelism, passphrase_ptr, salt_ptr, hash_ptr)
382
+ else
383
+ # Only require the time taken to be approximately correct. Scale up
384
+ # geometrically using Fibonacci numbers (as per PuTTY's
385
+ # implementation).
386
+ prev_passes = 1
387
+ passes = 1
388
+
389
+ loop do
390
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
391
+ argon2_hash(argon2_params.type, passes, argon2_params.memory, argon2_params.parallelism, passphrase_ptr, salt_ptr, hash_ptr)
392
+ elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000
393
+ break if (elapsed >= argon2_params.desired_time)
394
+ hash_ptr.clear
395
+ new_passes = passes + prev_passes
396
+ break if new_passes > 2**32 # maximum allowed by argon2_hash parameter data type
397
+ prev_passes, passes = passes, new_passes
398
+ end
399
+ end
400
+
401
+ passphrase_ptr.clear
402
+ key = hash_ptr.get_bytes(0, cipher.key_len)
403
+ iv = hash_ptr.get_bytes(cipher.key_len, cipher.iv_len)
404
+ mac_key = hash_ptr.get_bytes(cipher.key_len + cipher.iv_len, FORMAT_3_MAC_KEY_LENGTH)
405
+ argon2_params = argon2_params.complete(passes, salt)
406
+ hash_ptr.clear
407
+ [mac_key, key, iv, argon2_params]
408
+ ensure
409
+ # Calling free isn't actually required, but this releases the memory
410
+ # sooner.
411
+ hash_ptr.free
412
+ salt_ptr.free
413
+ passphrase_ptr.free
414
+ end
415
+ end
416
+
417
+ # Creates an `FFI::MemoryPointer` containing the raw bytes from `string`
418
+ # without a null terminator.
419
+ #
420
+ # @param string [String] The bytes to use for the `FFI::MemoryPointer`.
421
+ #
422
+ # @return [FFI::MemoryPointer] A new `FFI::MemoryPointer` containing the
423
+ # raw bytes from `string`.
424
+ def pointer_for_bytes(string)
425
+ FFI::MemoryPointer.new(:char, string.bytesize).tap do |ptr|
426
+ ptr.put_bytes(0, string)
427
+ end
428
+ end
429
+
430
+ # Calls the libargon2 `argon2_hash` function to obtain a raw hash using
431
+ # version 13 of the algorithm.
432
+ #
433
+ # @param type [Symbol] The variant of Argon2 to use. (`:d`, `:i` or
434
+ # `:id`).
435
+ # @param iterations [Integer] The number of iterations to use.
436
+ # @param memory [Integer] Memory usage in kibibytes.
437
+ # @param passhrase [FFI::MemoryPointer] The passphrase.
438
+ # @param salt [FFI::MemoryPointer] The salt.
439
+ # @param hash [FFI::MemoryPointer] A buffer to write the raw hash to.
440
+ #
441
+ # @raise [Argon2Error] If `argon2_hash` returns an error.
442
+ def argon2_hash(type, iterations, memory, parallelism, passphrase, salt, hash)
443
+ res = Libargon2.argon2_hash(iterations, memory, parallelism,
444
+ passphrase, passphrase.size, salt, salt.size,
445
+ hash, hash.size, FFI::Pointer::NULL, 0, type, :version_13)
446
+
447
+ unless res == Libargon2::ARGON2_OK
448
+ raise Argon2Error.new(res, Libargon2.argon2_error_message(res))
449
+ end
450
+ end
451
+
452
+ # Derives an encryption key of the specified length from a passphrase for
453
+ # use in format 2 files.
188
454
  #
189
455
  # @param passphrase [String] The passphrase to use.
190
456
  # @param key_length [Integer] The length of the desired key in bytes.
191
457
  #
192
- # @return [String] The generated key.
193
- def generate_encryption_key(passphrase, key_length)
458
+ # @return [String] The derived encryption key.
459
+ def derive_format_2_encryption_key(passphrase, key_length)
194
460
  key = String.new
195
461
  key_digest = ::OpenSSL::Digest::SHA1.new
196
462
  iteration = 0
@@ -209,47 +475,72 @@ module PuTTY
209
475
  key[0, key_length]
210
476
  end
211
477
 
478
+ # Derives a MAC key from a passphrase for use in format 2 files.
479
+ #
480
+ # @param passphrase [String] The passphrase to use or `nil` if not
481
+ # encrypted.
482
+ #
483
+ # @return [String] The derived MAC key.
484
+ def derive_format_2_mac_key(passphrase)
485
+ key = ::OpenSSL::Digest::SHA1.new
486
+ key.update(FORMAT_2_MAC_KEY)
487
+ key.update(passphrase) if passphrase
488
+ key.digest
489
+ end
490
+
212
491
  # Computes the value of the Private-MAC field given the passphrase,
213
492
  # encryption type and padded private blob (the value of the private blob
214
493
  # after padding bytes have been appended prior to encryption).
215
494
  #
216
- # @param passphrase [String] The encryption passphrase.
495
+ # @param format [Integer] The format of the .ppk file.
496
+ # @param mac_key [String] The HMAC key.
217
497
  # @param encryption_type [String] The value of the Encryption field.
218
498
  # @param padded_private_blob [String] The private blob after padding bytes
219
499
  # have been appended prior to encryption.
220
500
  #
221
501
  # @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
502
+ def compute_private_mac(format, mac_key, encryption_type, padded_private_blob)
503
+ digest = format <= 2 ? ::OpenSSL::Digest::SHA1 : ::OpenSSL::Digest::SHA256
226
504
  data = Util.ssh_pack(@algorithm, encryption_type, @comment || '', @public_blob, padded_private_blob)
227
- ::OpenSSL::HMAC.hexdigest(::OpenSSL::Digest::SHA1.new, key.digest, data)
505
+ ::OpenSSL::HMAC.hexdigest(digest.new, mac_key, data)
228
506
  end
229
507
 
230
508
  # Handles reading .ppk files.
231
509
  #
232
510
  # @private
233
511
  class Reader
234
- # Opens a .ppk file for reading, creates a new instance of `Reader` and
235
- # yields it to the caller.
512
+ # Opens a .ppk file for reading (or uses the provided `IO`-like
513
+ # instance), creates a new instance of `Reader` and yields it to the
514
+ # caller.
236
515
  #
237
- # @param path [Object] The path of the .ppk file to be read.
516
+ # @param path_or_io [Object] The path of the .ppk file to be read or an
517
+ # `IO`-like object.
238
518
  #
239
519
  # @return [Object] The result of yielding to the caller.
240
520
  #
241
521
  # 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)
522
+ def self.open(path_or_io)
523
+ if path_or_io.kind_of?(String) || path_or_io.kind_of?(Pathname)
524
+ File.open(path_or_io.to_s, 'rb') do |file|
525
+ yield Reader.new(file)
526
+ end
527
+ else
528
+ path_or_io.binmode if path_or_io.respond_to?(:binmode)
529
+
530
+ unless path_or_io.respond_to?(:getbyte)
531
+ path_or_io = GetbyteIo.new(path_or_io)
532
+ end
533
+
534
+ yield Reader.new(path_or_io)
245
535
  end
246
536
  end
247
537
 
248
- # Initializes a new {Reader} with an {IO} to read from.
538
+ # Initializes a new {Reader} with an `IO`-like instance to read from.
249
539
  #
250
- # @param file [IO] The file to read from.
540
+ # @param file [Object] The `IO`-like instance to read from.
251
541
  def initialize(file)
252
542
  @file = file
543
+ @consumed_byte = nil
253
544
  end
254
545
 
255
546
  # Reads the next field from the file.
@@ -266,6 +557,46 @@ module PuTTY
266
557
  line.byteslice(name.bytesize + 2, line.bytesize - name.bytesize - 2)
267
558
  end
268
559
 
560
+ # Reads the next field from the file.
561
+ #
562
+ # @param name_regexp [Regexp] A `Regexp` that matches the expected field
563
+ # name.
564
+ #
565
+ # @return [String] The value of the field if the regular expression has
566
+ # no captures.
567
+ # @return [Array] An array containing the regular expression captures as
568
+ # the first elements and the value of the field as the last element.
569
+ #
570
+ # @raise [FormatError] If the current position in the file was not the
571
+ # start of a field with the expected name.
572
+ def field_matching(name_regexp)
573
+ line = read_line
574
+ line_regexp = Regexp.new("\\A#{name_regexp.source}: ", name_regexp.options)
575
+ match = line_regexp.match(line)
576
+ raise FormatError, "Expected field matching #{name_regexp}, but found #{line}" unless match
577
+ prefix = match[0]
578
+ value = line.byteslice(prefix.bytesize, line.bytesize - prefix.bytesize)
579
+ captures = match.captures
580
+ captures.empty? ? value : captures + [value]
581
+ end
582
+
583
+ # Reads the next field from the file as an unsigned integer.
584
+ #
585
+ # @param name [String] The expected field name.
586
+ #
587
+ # @return [Integer] The value of the field.
588
+ #
589
+ # @raise [FormatError] If the current position in the file was not the
590
+ # start of a field with the expected name.
591
+ # @raise [FormatError] If the field did not contain a positive integer.
592
+ def unsigned_integer(name, maximum: nil)
593
+ value = field(name)
594
+ value = value =~ /\A[0-9]+\z/ && value.to_i
595
+ raise FormatError, "Expected field #{name} to contain an unsigned integer value, but found #{value}" unless value
596
+ raise FormatError, "Expected field #{name} to have a maximum of #{maximum}, but found #{value}" if maximum && value > maximum
597
+ value
598
+ end
599
+
269
600
  # Reads a blob from the file consisting of a Lines field whose value
270
601
  # gives the number of Base64 encoded lines in the blob.
271
602
  #
@@ -276,28 +607,68 @@ module PuTTY
276
607
  # @raise [FormatError] If the value of the Lines field is not a
277
608
  # positive integer.
278
609
  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
610
+ lines = unsigned_integer("#{name}-Lines")
611
+ lines.times.map { read_line }.join("\n").unpack('m48').first
282
612
  end
283
613
 
284
614
  private
285
615
 
286
- # Reads a single new-line (\n or \r\n) terminated line from the file,
287
- # removing the new-line character.
616
+ # Reads a single new-line (\n, \r\n or \r) terminated line from the
617
+ # file, removing the new-line character.
288
618
  #
289
619
  # @return [String] The line.
290
620
  #
291
621
  # @raise [FormatError] If the end of file was detected before reading a
292
622
  # line.
293
623
  def read_line
294
- @file.readline("\n").chomp("\n")
295
- rescue EOFError
624
+ line = ''.b
625
+
626
+ if @consumed_byte
627
+ line << @consumed_byte
628
+ @consumed_byte = nil
629
+ end
630
+
631
+ while byte = @file.getbyte
632
+ return line if byte == 0x0a
633
+
634
+ if byte == 0x0d
635
+ byte = @file.getbyte
636
+ return line if !byte || byte == 0x0a
637
+ @consumed_byte = byte
638
+ return line
639
+ end
640
+
641
+ line << byte
642
+ end
643
+
644
+ return line if line.bytesize > 0
296
645
  raise FormatError, 'Truncated ppk file detected'
297
646
  end
298
647
  end
299
648
  private_constant :Reader
300
649
 
650
+ # Wraps an `IO`-like instance, providing an implementation of `#getbyte`.
651
+ # Allows reading from `IO`-like instances that only provide `#read`.
652
+ class GetbyteIo
653
+ # Initializes a new {GetbyteIO} with the given `IO`-like instance.
654
+ #
655
+ # @param io [Object] An `IO`-like instance.
656
+ def initialize(io)
657
+ @io = io
658
+ @outbuf = ' '.b
659
+ end
660
+
661
+ # Gets the next 8-bit byte (0..255) from the `IO`-like instance.
662
+ #
663
+ # @return [Integer] the next byte or `nil` if the end of the stream has
664
+ # been reached.
665
+ def getbyte
666
+ s = @io.read(1, @outbuf)
667
+ s && s.getbyte(0)
668
+ end
669
+ end
670
+ private_constant :GetbyteIo
671
+
301
672
  # Handles writing .ppk files.
302
673
  #
303
674
  # @private
@@ -307,24 +678,31 @@ module PuTTY
307
678
  # @return [Integer] The number of bytes that have been written.
308
679
  attr_reader :bytes_written
309
680
 
310
- # Opens a .ppk file for writing, creates a new instance of `Writer` and
311
- # yields it to the caller.
681
+ # Opens a .ppk file for writing (or uses the provided `IO`-like
682
+ # instance), creates a new instance of `Writer` and yields it to the
683
+ # caller.
312
684
  #
313
- # @param path [Object] The path of the .ppk file to be written.
685
+ # @param path_or_io [Object] The path of the .ppk file to be written or
686
+ # an `IO`-like instance.
314
687
  #
315
688
  # @return [Object] The result of yielding to the caller.
316
689
  #
317
690
  # @raise [Errno::ENOENT] If a directory specified by `path` does not
318
691
  # exist.
319
- def self.open(path)
320
- File.open(path.to_s, 'wb') do |file|
321
- yield Writer.new(file)
692
+ def self.open(path_or_io)
693
+ if path_or_io.kind_of?(String) || path_or_io.kind_of?(Pathname)
694
+ File.open(path_or_io.to_s, 'wb') do |file|
695
+ yield Writer.new(file)
696
+ end
697
+ else
698
+ path_or_io.binmode if path_or_io.respond_to?(:binmode)
699
+ yield Writer.new(path_or_io)
322
700
  end
323
701
  end
324
702
 
325
- # Initializes a new {Writer} with an {IO} to write to.
703
+ # Initializes a new {Writer} with an `IO`-like instance to write to.
326
704
  #
327
- # @param file [IO] The file to write to.
705
+ # @param file [Object] The `IO`-like instance to write to.
328
706
  def initialize(file)
329
707
  @file = file
330
708
  @bytes_written = 0
@@ -333,7 +711,7 @@ module PuTTY
333
711
  # Writes a field to the file.
334
712
  #
335
713
  # @param name [String] The field name.
336
- # @param value [String] The field value.
714
+ # @param value [Object] The field value.
337
715
  def field(name, value)
338
716
  write(name)
339
717
  write(': ')
@@ -358,9 +736,9 @@ module PuTTY
358
736
 
359
737
  private
360
738
 
361
- # Writes a line separator to the file (\r\n on all platforms).
739
+ # Writes a line separator to the file (\n on all platforms).
362
740
  def write_line
363
- write("\r\n")
741
+ write("\n")
364
742
  end
365
743
 
366
744
  # Writes a string to the file.