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