putty-key 1.0.1 → 1.1.1

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