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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +2 -1
- data/CHANGES.md +23 -0
- data/Gemfile +10 -5
- data/LICENSE +1 -1
- data/README.md +32 -6
- data/Rakefile +24 -0
- data/lib/putty/key/argon2_params.rb +101 -0
- data/lib/putty/key/error.rb +17 -0
- data/lib/putty/key/libargon2.rb +54 -0
- data/lib/putty/key/openssl.rb +279 -48
- data/lib/putty/key/ppk.rb +482 -104
- data/lib/putty/key/util.rb +10 -10
- data/lib/putty/key/version.rb +1 -1
- data/lib/putty/key.rb +6 -6
- data/putty-key.gemspec +11 -2
- data/test/argon2_params_test.rb +144 -0
- data/test/fixtures/{dss-1024-encrypted.ppk → dss-1024-encrypted-format-2.ppk} +17 -17
- data/test/fixtures/dss-1024-encrypted-format-3.ppk +22 -0
- data/test/fixtures/{dss-1024.ppk → dss-1024-format-2.ppk} +17 -17
- data/test/fixtures/dss-1024-format-3.ppk +17 -0
- data/test/fixtures/{ecdsa-sha2-nistp256-encrypted.ppk → ecdsa-sha2-nistp256-encrypted-format-2.ppk} +10 -10
- data/test/fixtures/ecdsa-sha2-nistp256-encrypted-format-3.ppk +15 -0
- data/test/fixtures/{ecdsa-sha2-nistp256.ppk → ecdsa-sha2-nistp256-format-2.ppk} +10 -10
- data/test/fixtures/ecdsa-sha2-nistp256-format-3.ppk +10 -0
- data/test/fixtures/{ecdsa-sha2-nistp384-encrypted.ppk → ecdsa-sha2-nistp384-encrypted-format-2.ppk} +11 -11
- data/test/fixtures/ecdsa-sha2-nistp384-encrypted-format-3.ppk +16 -0
- data/test/fixtures/{ecdsa-sha2-nistp384.ppk → ecdsa-sha2-nistp384-format-2.ppk} +11 -11
- data/test/fixtures/ecdsa-sha2-nistp384-format-3.ppk +11 -0
- data/test/fixtures/{ecdsa-sha2-nistp521-encrypted.ppk → ecdsa-sha2-nistp521-encrypted-format-2.ppk} +12 -12
- data/test/fixtures/ecdsa-sha2-nistp521-encrypted-format-3.ppk +17 -0
- data/test/fixtures/{ecdsa-sha2-nistp521.ppk → ecdsa-sha2-nistp521-format-2.ppk} +12 -12
- data/test/fixtures/ecdsa-sha2-nistp521-format-3.ppk +12 -0
- data/test/fixtures/{rsa-2048-encrypted.ppk → rsa-2048-encrypted-format-2.ppk} +26 -26
- data/test/fixtures/rsa-2048-encrypted-format-3.ppk +31 -0
- data/test/fixtures/{rsa-2048.ppk → rsa-2048-format-2.ppk} +26 -26
- data/test/fixtures/rsa-2048-format-3.ppk +26 -0
- data/test/fixtures/test-blank-comment.ppk +11 -11
- data/test/fixtures/test-empty-blobs-encrypted.ppk +6 -0
- data/test/fixtures/test-empty-blobs.ppk +6 -0
- data/test/fixtures/{test-encrypted.ppk → test-encrypted-format-2.ppk} +11 -11
- data/test/fixtures/test-encrypted-format-3.ppk +16 -0
- data/test/fixtures/test-encrypted-type-d-format-3.ppk +16 -0
- data/test/fixtures/test-encrypted-type-i-format-3.ppk +16 -0
- data/test/fixtures/{test-unix-line-endings.ppk → test-format-2.ppk} +0 -0
- data/test/fixtures/test-format-3.ppk +11 -0
- data/test/fixtures/test-invalid-argon2-memory-for-libargon2.ppk +16 -0
- data/test/fixtures/test-invalid-argon2-memory-maximum.ppk +16 -0
- data/test/fixtures/test-invalid-argon2-memory.ppk +16 -0
- data/test/fixtures/test-invalid-argon2-parallelism-maximum.ppk +16 -0
- data/test/fixtures/test-invalid-argon2-parallelism.ppk +16 -0
- data/test/fixtures/test-invalid-argon2-passes-maximum.ppk +16 -0
- data/test/fixtures/test-invalid-argon2-passes.ppk +16 -0
- data/test/fixtures/test-invalid-argon2-salt.ppk +16 -0
- data/test/fixtures/test-invalid-blob-lines.ppk +11 -11
- data/test/fixtures/test-invalid-encryption-type.ppk +11 -11
- data/test/fixtures/test-invalid-format-1.ppk +11 -11
- data/test/fixtures/{test-invalid-format-3.ppk → test-invalid-format-4.ppk} +11 -11
- data/test/fixtures/test-invalid-key-derivation.ppk +16 -0
- data/test/fixtures/test-invalid-private-mac.ppk +11 -11
- data/test/fixtures/test-legacy-mac-line-endings.ppk +1 -0
- data/test/fixtures/test-missing-final-line-ending.ppk +11 -0
- data/test/fixtures/test-truncated.ppk +10 -10
- data/test/fixtures/{test.ppk → test-windows-line-endings.ppk} +0 -0
- data/test/openssl_test.rb +243 -53
- data/test/ppk_test.rb +325 -44
- data/test/test_helper.rb +10 -3
- data.tar.gz.sig +0 -0
- metadata +73 -23
- 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
|
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
|
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.
|
21
|
-
#
|
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
|
25
|
-
# was only used briefly early on in the development
|
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
|
28
|
-
|
29
|
-
private_constant :
|
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
|
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
|
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
|
66
|
-
#
|
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
|
-
|
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
|
78
|
-
|
79
|
-
@algorithm = reader.
|
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
|
-
|
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
|
-
|
106
|
-
|
107
|
-
|
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
|
-
#
|
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
|
118
|
-
#
|
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
|
-
#
|
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
|
-
|
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, '
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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(
|
256
|
+
private_mac = compute_private_mac(format, mac_key, encryption_type, padded_private_blob)
|
174
257
|
|
175
|
-
Writer.open(
|
176
|
-
writer.field(
|
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
|
-
#
|
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
|
193
|
-
def
|
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
|
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(
|
223
|
-
|
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(
|
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
|
235
|
-
# yields it to the
|
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
|
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(
|
243
|
-
|
244
|
-
|
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
|
538
|
+
# Initializes a new {Reader} with an `IO`-like instance to read from.
|
249
539
|
#
|
250
|
-
# @param file [
|
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 =
|
280
|
-
|
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
|
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
|
-
|
295
|
-
|
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
|
311
|
-
# yields it to the
|
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
|
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(
|
320
|
-
|
321
|
-
|
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
|
703
|
+
# Initializes a new {Writer} with an `IO`-like instance to write to.
|
326
704
|
#
|
327
|
-
# @param file [
|
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 [
|
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 (\
|
739
|
+
# Writes a line separator to the file (\n on all platforms).
|
362
740
|
def write_line
|
363
|
-
write("\
|
741
|
+
write("\n")
|
364
742
|
end
|
365
743
|
|
366
744
|
# Writes a string to the file.
|