putty-key 1.0.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 (49) hide show
  1. checksums.yaml +7 -0
  2. checksums.yaml.gz.sig +3 -0
  3. data.tar.gz.sig +1 -0
  4. data/.yardopts +7 -0
  5. data/CHANGES.md +4 -0
  6. data/Gemfile +14 -0
  7. data/LICENSE +19 -0
  8. data/README.md +137 -0
  9. data/Rakefile +110 -0
  10. data/lib/putty/key.rb +25 -0
  11. data/lib/putty/key/error.rb +26 -0
  12. data/lib/putty/key/openssl.rb +182 -0
  13. data/lib/putty/key/ppk.rb +374 -0
  14. data/lib/putty/key/util.rb +128 -0
  15. data/lib/putty/key/version.rb +6 -0
  16. data/putty-key.gemspec +29 -0
  17. data/test/fixtures/dss-1024-encrypted.ppk +17 -0
  18. data/test/fixtures/dss-1024.pem +12 -0
  19. data/test/fixtures/dss-1024.ppk +17 -0
  20. data/test/fixtures/ecdsa-secp256k1.pem +5 -0
  21. data/test/fixtures/ecdsa-sha2-nistp256-encrypted.ppk +10 -0
  22. data/test/fixtures/ecdsa-sha2-nistp256.pem +5 -0
  23. data/test/fixtures/ecdsa-sha2-nistp256.ppk +10 -0
  24. data/test/fixtures/ecdsa-sha2-nistp384-encrypted.ppk +11 -0
  25. data/test/fixtures/ecdsa-sha2-nistp384.pem +6 -0
  26. data/test/fixtures/ecdsa-sha2-nistp384.ppk +11 -0
  27. data/test/fixtures/ecdsa-sha2-nistp521-encrypted.ppk +12 -0
  28. data/test/fixtures/ecdsa-sha2-nistp521.pem +7 -0
  29. data/test/fixtures/ecdsa-sha2-nistp521.ppk +12 -0
  30. data/test/fixtures/rsa-2048-encrypted.ppk +26 -0
  31. data/test/fixtures/rsa-2048.pem +27 -0
  32. data/test/fixtures/rsa-2048.ppk +26 -0
  33. data/test/fixtures/test-blank-comment.ppk +11 -0
  34. data/test/fixtures/test-encrypted.ppk +11 -0
  35. data/test/fixtures/test-invalid-blob-lines.ppk +11 -0
  36. data/test/fixtures/test-invalid-encryption-type.ppk +11 -0
  37. data/test/fixtures/test-invalid-format-1.ppk +11 -0
  38. data/test/fixtures/test-invalid-format-3.ppk +11 -0
  39. data/test/fixtures/test-invalid-private-mac.ppk +11 -0
  40. data/test/fixtures/test-truncated.ppk +10 -0
  41. data/test/fixtures/test-unix-line-endings.ppk +11 -0
  42. data/test/fixtures/test.ppk +11 -0
  43. data/test/openssl_test.rb +252 -0
  44. data/test/ppk_test.rb +247 -0
  45. data/test/test_helper.rb +81 -0
  46. data/test/util_test.rb +180 -0
  47. data/test/version_test.rb +7 -0
  48. metadata +124 -0
  49. metadata.gz.sig +0 -0
@@ -0,0 +1,374 @@
1
+ require 'openssl'
2
+
3
+ module PuTTY
4
+ module Key
5
+ # Represents a PuTTY private key (.ppk) file.
6
+ #
7
+ # The {PPK} {#initialize constructor} can be used to either create an
8
+ # uninitialized key or to load a .ppk file.
9
+ #
10
+ # The {#save} method can be used to save a {PPK} instance to a .ppk file.
11
+ #
12
+ # The {#algorithm}, {#comment}, {#public_blob} and {#private_blob}
13
+ # attributes provide access to the high level fields of the PuTTY private
14
+ # key as binary `String` instances. The structure of the two blobs will vary
15
+ # based on the algorithm.
16
+ #
17
+ # Encrypted .ppk files can be read and written by specifying a passphrase
18
+ # when loading or saving. As supported by PuTTY, files are always encrypted
19
+ # using AES in CBC mode with a 256-bit key derived from the passphrase using
20
+ # SHA-1.
21
+ #
22
+ # The {PPK} class supports files corresponding to PuTTY's format 2. Format 1
23
+ # was only used briefly early on in the development of the .ppk format.
24
+ class PPK
25
+ # String used in the computation of the private MAC.
26
+ MAC_KEY = 'putty-private-key-file-mac-key'#.b#.freeze
27
+ private_constant :MAC_KEY
28
+
29
+ # The default (and only supported) encryption algorithm.
30
+ DEFAULT_ENCRYPTION_TYPE = 'aes256-cbc'.freeze
31
+
32
+ # The default (and only supported) PuTTY private key file format.
33
+ DEFAULT_FORMAT = 2
34
+
35
+ # The key's algorithm, for example, 'ssh-rsa' or 'ssh-dss'.
36
+ #
37
+ # @return [String] The key's algorithm, for example, 'ssh-rsa' or
38
+ # 'ssh-dss'.
39
+ attr_accessor :algorithm
40
+
41
+ # A comment to describe the PuTTY private key.
42
+ #
43
+ # @return [String] A comment to describe the PuTTY private key.
44
+ attr_accessor :comment
45
+
46
+ # Get or set the public component of the key.
47
+ #
48
+ # @return [String] The public component of the key.
49
+ attr_accessor :public_blob
50
+
51
+ # The private component of the key (after decryption when loading and
52
+ # before encryption when saving).
53
+ #
54
+ # Note that when loading an encrypted .ppk file, this may include
55
+ # additional 'random' suffix used as padding.
56
+ #
57
+ # @return [String] The private component of the key
58
+ attr_accessor :private_blob
59
+
60
+ # Constructs a new {PPK} instance either uninitialized, or by loading a
61
+ # .ppk file.
62
+ #
63
+ # @param path [Object] Set to the path of a .ppk file to load the file.
64
+ # Leave as `nil` to leave the new {PPK} instance uninitialized.
65
+ # @param passphrase [String] The passphrase to use when loading an
66
+ # encrypted .ppk file.
67
+ #
68
+ # @raise [Errno::ENOENT] If the file specified by `path` does not exist.
69
+ # @raise [ArgumentError] If the .ppk file was encrypted, but either no
70
+ # passphrase or an incorrect passphrase was supplied.
71
+ # @raise [FormatError] If the .ppk file is malformed.
72
+ def initialize(path = nil, passphrase = nil)
73
+ passphrase = nil if passphrase && passphrase.to_s.empty?
74
+
75
+ if path
76
+ encryption_type, encrypted_private_blob, private_mac = Reader.open(path) do |reader|
77
+ @algorithm = reader.field('PuTTY-User-Key-File-2')
78
+ encryption_type = reader.field('Encryption')
79
+ @comment = reader.field('Comment')
80
+ @public_blob = reader.blob('Public')
81
+ encrypted_private_blob = reader.blob('Private')
82
+ private_mac = reader.field('Private-MAC')
83
+ [encryption_type, encrypted_private_blob, private_mac]
84
+ end
85
+
86
+ if encryption_type == 'none'
87
+ passphrase = nil
88
+ @private_blob = encrypted_private_blob
89
+ else
90
+ raise FormatError, "The ppk file is encrypted with #{encryption_type}, which is not supported" unless encryption_type == DEFAULT_ENCRYPTION_TYPE
91
+ raise ArgumentError, 'The ppk file is encrypted, a passphrase must be supplied' unless passphrase
92
+
93
+ # PuTTY uses a zero IV.
94
+ cipher = ::OpenSSL::Cipher::AES.new(256, :CBC)
95
+ cipher.decrypt
96
+ cipher.key = generate_encryption_key(passphrase, cipher.key_len)
97
+ cipher.padding = 0
98
+ @private_blob = cipher.update(encrypted_private_blob) + cipher.final
99
+ end
100
+
101
+ expected_private_mac = compute_private_mac(passphrase, encryption_type, @private_blob)
102
+
103
+ unless private_mac == expected_private_mac
104
+ raise ArgumentError, 'Incorrect passphrase supplied' if passphrase
105
+ raise FormatError, "Invalid Private MAC (expected #{expected_private_mac}, but found #{private_mac})"
106
+ end
107
+ end
108
+ end
109
+
110
+ # Saves this PuTTY private key instance to a .ppk file.
111
+ #
112
+ # The {#algorithm}, {#private_blob} and {#public_blob} attributes must
113
+ # have been set before calling {#save}.
114
+ #
115
+ # @param path [Object] The path to write to. If a file already exists, it
116
+ # will be overwritten.
117
+ # @param passphrase [String] Set `passphrase` to encrypt the .ppk file
118
+ # using the specified passphrase. Leave as `nil` to create an
119
+ # unencrypted .ppk file.
120
+ # @param encryption_type [String] The encryption algorithm to use.
121
+ # Defaults to and currently only supports `'aes256-cbc'`.
122
+ # @param format [Integer] The format of .ppk file to create. Defaults to
123
+ # and currently only supports `2`.
124
+ #
125
+ # @return [Integer] The number of bytes written to the file.
126
+ #
127
+ # @raise [InvalidStateError] If either of the {#algorithm},
128
+ # {#private_blob} or {#public_blob} attributes have not been set.
129
+ # @raise [ArgumentError] If `path` is nil.
130
+ # @raise [ArgumentError] If a passphrase has been specified and
131
+ # `encryption_type` is not `'aes256-cbc'`.
132
+ # @raise [ArgumentError] If `format` is not `2`.
133
+ # @raise [Errno::ENOENT] If a directory specified by `path` does not
134
+ # exist.
135
+ def save(path, passphrase = nil, encryption_type: DEFAULT_ENCRYPTION_TYPE, format: DEFAULT_FORMAT)
136
+ raise InvalidStateError, 'algorithm must be set before calling save' unless @algorithm
137
+ raise InvalidStateError, 'public_blob must be set before calling save' unless @public_blob
138
+ raise InvalidStateError, 'private_blob must be set before calling save' unless @private_blob
139
+
140
+ passphrase = nil if passphrase && passphrase.to_s.empty?
141
+ encryption_type = 'none' unless passphrase
142
+
143
+ raise ArgumentError, 'An output path must be specified' unless path
144
+
145
+ if passphrase
146
+ raise ArgumentError, 'An encryption_type must be specified if a passphrase is specified' unless encryption_type
147
+ raise ArgumentError, "Unsupported encryption_type: #{encryption_type}" unless encryption_type == DEFAULT_ENCRYPTION_TYPE
148
+ end
149
+
150
+ raise ArgumentError, 'A format must be specified' unless format
151
+ raise ArgumentError, "Unsupported format: #{format}" unless format == DEFAULT_FORMAT
152
+
153
+ padded_private_blob = @private_blob
154
+
155
+ if passphrase
156
+ # Pad using an SHA-1 hash of the unpadded private blob in order to
157
+ # prevent an easily known plaintext attack on the last block.
158
+ cipher = ::OpenSSL::Cipher::AES.new(256, :CBC)
159
+ cipher.encrypt
160
+ padding_length = cipher.block_size - (padded_private_blob.bytesize % cipher.block_size)
161
+ padded_private_blob += ::OpenSSL::Digest::SHA1.new(padded_private_blob).digest[0, padding_length]
162
+
163
+ # PuTTY uses a zero IV.
164
+ cipher.key = generate_encryption_key(passphrase, cipher.key_len)
165
+ cipher.padding = 0
166
+ encrypted_private_blob = cipher.update(padded_private_blob) + cipher.final
167
+ else
168
+ encrypted_private_blob = private_blob
169
+ end
170
+
171
+ private_mac = compute_private_mac(passphrase, encryption_type, padded_private_blob)
172
+
173
+ Writer.open(path) do |writer|
174
+ writer.field('PuTTY-User-Key-File-2', @algorithm)
175
+ writer.field('Encryption', encryption_type)
176
+ writer.field('Comment', @comment)
177
+ writer.blob('Public', @public_blob)
178
+ writer.blob('Private', encrypted_private_blob)
179
+ writer.field('Private-MAC', private_mac)
180
+ end
181
+ end
182
+
183
+ private
184
+
185
+ # Generates an encryption key of the specified length from a passphrase.
186
+ #
187
+ # @param passphrase [String] The passphrase to use.
188
+ # @param key_length [Integer] The length of the desired key in bytes.
189
+ #
190
+ # @return [String] The generated key.
191
+ def generate_encryption_key(passphrase, key_length)
192
+ key = String.new
193
+ key_digest = ::OpenSSL::Digest::SHA1.new
194
+ iteration = 0
195
+
196
+ while true
197
+ key_digest.update([iteration].pack('N'))
198
+ key_digest.update(passphrase.bytes.pack('c*'))
199
+ key += key_digest.digest
200
+
201
+ break if key.bytesize > key_length
202
+
203
+ key_digest.reset
204
+ iteration += 1
205
+ end
206
+
207
+ key[0, key_length]
208
+ end
209
+
210
+ # Computes the value of the Private-MAC field given the passphrase,
211
+ # encryption type and padded private blob (the value of the private blob
212
+ # after padding bytes have been appended prior to encryption).
213
+ #
214
+ # @param passphrase [String] The encryption passphrase.
215
+ # @param encryption_type [String] The value of the Encryption field.
216
+ # @param padded_private_blob [String] The private blob after padding bytes
217
+ # have been appended prior to encryption.
218
+ #
219
+ # @return [String] The computed private MAC.
220
+ def compute_private_mac(passphrase, encryption_type, padded_private_blob)
221
+ key = ::OpenSSL::Digest::SHA1.new
222
+ key.update(MAC_KEY)
223
+ key.update(passphrase) if passphrase
224
+ data = Util.ssh_pack(@algorithm, encryption_type, @comment || '', @public_blob, padded_private_blob)
225
+ ::OpenSSL::HMAC.hexdigest(::OpenSSL::Digest::SHA1.new, key.digest, data)
226
+ end
227
+
228
+ # Handles reading .ppk files.
229
+ #
230
+ # @private
231
+ class Reader
232
+ # Opens a .ppk file for reading, creates a new instance of `Reader` and
233
+ # yields it to the caller.
234
+ #
235
+ # @param path [Object] The path of the .ppk file to be read.
236
+ #
237
+ # @return [Object] The result of yielding to the caller.
238
+ #
239
+ # raise [Errno::ENOENT] If the file specified by `path` does not exist.
240
+ def self.open(path)
241
+ File.open(path.to_s, 'rb') do |file|
242
+ yield Reader.new(file)
243
+ end
244
+ end
245
+
246
+ # Initializes a new {Reader} with an {IO} to read from.
247
+ #
248
+ # @param file [IO] The file to read from.
249
+ def initialize(file)
250
+ @file = file
251
+ end
252
+
253
+ # Reads the next field from the file.
254
+ #
255
+ # @param name [String] The expected field name.
256
+ #
257
+ # @return [String] The value of the field.
258
+ #
259
+ # @raise [FormatError] If the current position in the file was not the
260
+ # start of a field with the expected name.
261
+ def field(name)
262
+ line = read_line
263
+ raise FormatError, "Expected field #{name}, but found #{line}" unless line.start_with?("#{name}: ")
264
+ line.byteslice(name.bytesize + 2, line.bytesize - name.bytesize - 2)
265
+ end
266
+
267
+ # Reads a blob from the file consisting of a Lines field whose value
268
+ # gives the number of Base64 encoded lines in the blob.
269
+ #
270
+ # @return [String] The Base64-decoded value of the blob.
271
+ #
272
+ # @raise [FormatError] If there is not a blob starting at the current
273
+ # file position.
274
+ # @raise [FormatError] If the value of the Lines field is not a
275
+ # positive integer.
276
+ def blob(name)
277
+ lines = field("#{name}-Lines")
278
+ raise FormatError, "Invalid value for #{name}-Lines" unless lines =~ /\A\d+\z/
279
+ lines.to_i.times.map { read_line }.join("\n").unpack('m48').first
280
+ end
281
+
282
+ private
283
+
284
+ # Reads a single new-line (\n or \r\n) terminated line from the file,
285
+ # removing the new-line character.
286
+ #
287
+ # @return [String] The line.
288
+ #
289
+ # @raise [FormatError] If the end of file was detected before reading a
290
+ # line.
291
+ def read_line
292
+ @file.readline("\n").chomp("\n")
293
+ rescue EOFError
294
+ raise FormatError, 'Truncated ppk file detected'
295
+ end
296
+ end
297
+ private_constant :Reader
298
+
299
+ # Handles writing .ppk files.
300
+ #
301
+ # @private
302
+ class Writer
303
+ # The number of bytes that have been written.
304
+ #
305
+ # @return [Integer] The number of bytes that have been written.
306
+ attr_reader :bytes_written
307
+
308
+ # Opens a .ppk file for writing, creates a new instance of `Writer` and
309
+ # yields it to the caller.
310
+ #
311
+ # @param path [Object] The path of the .ppk file to be written.
312
+ #
313
+ # @return [Object] The result of yielding to the caller.
314
+ #
315
+ # @raise [Errno::ENOENT] If a directory specified by `path` does not
316
+ # exist.
317
+ def self.open(path)
318
+ File.open(path.to_s, 'wb') do |file|
319
+ yield Writer.new(file)
320
+ end
321
+ end
322
+
323
+ # Initializes a new {Writer} with an {IO} to write to.
324
+ #
325
+ # @param file [IO] The file to write to.
326
+ def initialize(file)
327
+ @file = file
328
+ @bytes_written = 0
329
+ end
330
+
331
+ # Writes a field to the file.
332
+ #
333
+ # @param name [String] The field name.
334
+ # @param value [String] The field value.
335
+ def field(name, value)
336
+ write(name)
337
+ write(': ')
338
+ write(value.to_s)
339
+ write_line
340
+ end
341
+
342
+ # Writes a blob to the file (Lines field and Base64 encoded value).
343
+ #
344
+ # @param name [String] The name of the blob (used as a prefix for the
345
+ # lines field).
346
+ # @param blob [String] The value of the blob. This is Base64 encoded
347
+ # before being written to the file.
348
+ def blob(name, blob)
349
+ lines = [blob].pack('m48').split("\n")
350
+ field("#{name}-Lines", lines.length)
351
+ lines.each do |line|
352
+ write(line)
353
+ write_line
354
+ end
355
+ end
356
+
357
+ private
358
+
359
+ # Writes a line separator to the file (\r\n on all platforms).
360
+ def write_line
361
+ write("\r\n")
362
+ end
363
+
364
+ # Writes a string to the file.
365
+ #
366
+ # @param string [String] The string to be written.
367
+ def write(string)
368
+ @bytes_written += @file.write(string)
369
+ end
370
+ end
371
+ private_constant :Writer
372
+ end
373
+ end
374
+ end
@@ -0,0 +1,128 @@
1
+ require 'openssl'
2
+ require 'stringio'
3
+
4
+ module PuTTY
5
+ module Key
6
+ # Utility methods used internally by PuTTY::Key.
7
+ #
8
+ # @private
9
+ module Util
10
+ # Encodes a list of values (String and OpenSSL::BN instances) according
11
+ # to RFC 4251 section 5 (as strings and mpints).
12
+ #
13
+ # No encoding conversion is performed on Strings.
14
+ #
15
+ # @param [Array] values An Array of String and OpenSSL::BN instances to
16
+ # be encoded.
17
+ #
18
+ # @return [String] A binary String containing the encoded values.
19
+ #
20
+ # @raise NilValueError If a value is `nil`.
21
+ def self.ssh_pack(*values)
22
+ return ''.b if values.empty?
23
+
24
+ values.map do |value|
25
+ raise NilValueError, 'values must not contain nil elements' unless value
26
+
27
+ if value.kind_of?(::OpenSSL::BN)
28
+ value = value.to_i
29
+ if value == 0
30
+ value = ''
31
+ else
32
+ bytes = []
33
+
34
+ if value > 0
35
+ begin
36
+ bytes << (value & 0xff)
37
+ value = value >> 8
38
+ end until value == 0
39
+
40
+ # 0 pad if necessary to resolve ambiguity with negative numbers
41
+ # in two's complement representation.
42
+ bytes << 0 if bytes.last & 0x80 != 0
43
+ else
44
+ begin
45
+ bytes << (value & 0xff)
46
+ value = value >> 8
47
+ end until value == -1 && bytes.last & 0x80 != 0
48
+ end
49
+
50
+ value = bytes.reverse!.pack('C*')
51
+ end
52
+ else
53
+ value = value.to_s.b
54
+ end
55
+
56
+ [value.bytesize].pack('N') + value
57
+ end.join
58
+ end
59
+
60
+ # Decodes a string containing RFC 4251 section 5 encoded string and
61
+ # mpint values.
62
+ #
63
+ # @param [String] encoded A binary {String} containing the encoded values.
64
+ # @param [Array<Symbol>] spec An array consisting of :string or :mpint
65
+ # elements describing the contents of encoded.
66
+ #
67
+ # @return [Array] An array of decoded (binary) {String} and {OpenSSL::BN}
68
+ # instances.
69
+ #
70
+ # @raise [ArgumentError] If `encoded` is `nil`.
71
+ # @raise [ArgumentError] If `encoded` does not use the `ASCII_8BIT`
72
+ # (binary) encoding.
73
+ # @raise [ArgumentError] If `spec` contains elements other than `:mpint`
74
+ # and `:string`.
75
+ # @raise [FormatError] If the encoded structure is malformed.
76
+ # @raise [FormatError] If `spec` contains more elements than are present
77
+ # within `encoded`.
78
+ def self.ssh_unpack(encoded, *spec)
79
+ raise ArgumentError, 'encoded must not be nil' unless encoded
80
+ encoded = encoded.to_s
81
+ raise ArgumentError, 'encoded must be a binary String' unless encoded.encoding == Encoding::ASCII_8BIT
82
+
83
+ io = StringIO.new(encoded)
84
+
85
+ spec.map do |type|
86
+ length_bytes = io.read(4)
87
+ raise FormatError, 'spec contains more elements than are contained within the encoded String' unless length_bytes
88
+ raise FormatError, 'Truncated length encountered' unless length_bytes.bytesize == 4
89
+
90
+ length = length_bytes.unpack('N').first
91
+
92
+ if length > 0
93
+ encoded_value = io.read(length)
94
+ raise FormatError, 'Missing value encountered' unless encoded_value
95
+ raise FormatError, 'Truncated value encountered' unless encoded_value.bytesize == length
96
+ else
97
+ encoded_value = nil
98
+ end
99
+
100
+ case type
101
+ when :string
102
+ encoded_value || String.new
103
+ when :mpint
104
+ value = 0
105
+
106
+ if encoded_value
107
+ bytes = encoded_value.unpack('C*')
108
+ bytes.each {|b| value = (value << 8) | b }
109
+
110
+ if bytes.first & 0x80 != 0
111
+ # A negative value. Reinterpret the bytes as the two's
112
+ # complement representation of the negative integer.
113
+ mask = 0xff
114
+ (bytes.length - 1).times { mask = mask << 8 | 0xff }
115
+ value = -(-value & mask)
116
+ end
117
+ end
118
+
119
+ ::OpenSSL::BN.new(value)
120
+ else
121
+ raise ArgumentError, 'spec must contain only :string and :mpint elements'
122
+ end
123
+ end
124
+ end
125
+ end
126
+ private_constant :Util
127
+ end
128
+ end