putty-key 1.0.1 → 1.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
-
[![
|
3
|
+
[![RubyGems](https://img.shields.io/gem/v/putty-key?logo=rubygems&label=Gem)](https://rubygems.org/gems/putty-key) [![Tests](https://github.com/philr/putty-key/workflows/Tests/badge.svg?branch=master&event=push)](https://github.com/philr/putty-key/actions?query=workflow%3ATests+branch%3Amaster+event%3Apush) [![Coverage Status](https://img.shields.io/coveralls/github/philr/putty-key/master?label=Coverage&logo=Coveralls)](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.
|