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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data.tar.gz.sig +0 -0
  4. data/CHANGES.md +16 -0
  5. data/Gemfile +0 -6
  6. data/LICENSE +1 -1
  7. data/README.md +32 -6
  8. data/Rakefile +24 -0
  9. data/lib/putty/key.rb +6 -6
  10. data/lib/putty/key/argon2_params.rb +101 -0
  11. data/lib/putty/key/error.rb +17 -0
  12. data/lib/putty/key/libargon2.rb +54 -0
  13. data/lib/putty/key/ppk.rb +469 -103
  14. data/lib/putty/key/util.rb +10 -10
  15. data/lib/putty/key/version.rb +1 -1
  16. data/putty-key.gemspec +11 -2
  17. data/test/argon2_params_test.rb +144 -0
  18. data/test/fixtures/{dss-1024-encrypted.ppk → dss-1024-encrypted-format-2.ppk} +17 -17
  19. data/test/fixtures/dss-1024-encrypted-format-3.ppk +22 -0
  20. data/test/fixtures/{dss-1024.ppk → dss-1024-format-2.ppk} +17 -17
  21. data/test/fixtures/dss-1024-format-3.ppk +17 -0
  22. data/test/fixtures/{ecdsa-sha2-nistp256-encrypted.ppk → ecdsa-sha2-nistp256-encrypted-format-2.ppk} +10 -10
  23. data/test/fixtures/ecdsa-sha2-nistp256-encrypted-format-3.ppk +15 -0
  24. data/test/fixtures/{ecdsa-sha2-nistp256.ppk → ecdsa-sha2-nistp256-format-2.ppk} +10 -10
  25. data/test/fixtures/ecdsa-sha2-nistp256-format-3.ppk +10 -0
  26. data/test/fixtures/{ecdsa-sha2-nistp384-encrypted.ppk → ecdsa-sha2-nistp384-encrypted-format-2.ppk} +11 -11
  27. data/test/fixtures/ecdsa-sha2-nistp384-encrypted-format-3.ppk +16 -0
  28. data/test/fixtures/{ecdsa-sha2-nistp384.ppk → ecdsa-sha2-nistp384-format-2.ppk} +11 -11
  29. data/test/fixtures/ecdsa-sha2-nistp384-format-3.ppk +11 -0
  30. data/test/fixtures/{ecdsa-sha2-nistp521-encrypted.ppk → ecdsa-sha2-nistp521-encrypted-format-2.ppk} +12 -12
  31. data/test/fixtures/ecdsa-sha2-nistp521-encrypted-format-3.ppk +17 -0
  32. data/test/fixtures/{ecdsa-sha2-nistp521.ppk → ecdsa-sha2-nistp521-format-2.ppk} +12 -12
  33. data/test/fixtures/ecdsa-sha2-nistp521-format-3.ppk +12 -0
  34. data/test/fixtures/{rsa-2048-encrypted.ppk → rsa-2048-encrypted-format-2.ppk} +26 -26
  35. data/test/fixtures/rsa-2048-encrypted-format-3.ppk +31 -0
  36. data/test/fixtures/{rsa-2048.ppk → rsa-2048-format-2.ppk} +26 -26
  37. data/test/fixtures/rsa-2048-format-3.ppk +26 -0
  38. data/test/fixtures/test-blank-comment.ppk +11 -11
  39. data/test/fixtures/test-empty-blobs-encrypted.ppk +6 -0
  40. data/test/fixtures/test-empty-blobs.ppk +6 -0
  41. data/test/fixtures/{test-encrypted.ppk → test-encrypted-format-2.ppk} +11 -11
  42. data/test/fixtures/test-encrypted-format-3.ppk +16 -0
  43. data/test/fixtures/test-encrypted-type-d-format-3.ppk +16 -0
  44. data/test/fixtures/test-encrypted-type-i-format-3.ppk +16 -0
  45. data/test/fixtures/{test-unix-line-endings.ppk → test-format-2.ppk} +0 -0
  46. data/test/fixtures/test-format-3.ppk +11 -0
  47. data/test/fixtures/test-invalid-argon2-memory-for-libargon2.ppk +16 -0
  48. data/test/fixtures/test-invalid-argon2-memory-maximum.ppk +16 -0
  49. data/test/fixtures/test-invalid-argon2-memory.ppk +16 -0
  50. data/test/fixtures/test-invalid-argon2-parallelism-maximum.ppk +16 -0
  51. data/test/fixtures/test-invalid-argon2-parallelism.ppk +16 -0
  52. data/test/fixtures/test-invalid-argon2-passes-maximum.ppk +16 -0
  53. data/test/fixtures/test-invalid-argon2-passes.ppk +16 -0
  54. data/test/fixtures/test-invalid-argon2-salt.ppk +16 -0
  55. data/test/fixtures/test-invalid-blob-lines.ppk +11 -11
  56. data/test/fixtures/test-invalid-encryption-type.ppk +11 -11
  57. data/test/fixtures/test-invalid-format-1.ppk +11 -11
  58. data/test/fixtures/{test-invalid-format-3.ppk → test-invalid-format-4.ppk} +11 -11
  59. data/test/fixtures/test-invalid-key-derivation.ppk +16 -0
  60. data/test/fixtures/test-invalid-private-mac.ppk +11 -11
  61. data/test/fixtures/test-legacy-mac-line-endings.ppk +1 -0
  62. data/test/fixtures/test-missing-final-line-ending.ppk +11 -0
  63. data/test/fixtures/test-truncated.ppk +10 -10
  64. data/test/fixtures/{test.ppk → test-windows-line-endings.ppk} +0 -0
  65. data/test/openssl_test.rb +243 -53
  66. data/test/ppk_test.rb +325 -44
  67. metadata +73 -23
  68. metadata.gz.sig +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9ee468e63692d4e452273f75c0e2349f451992fa906901f8856685c8c8f2bf82
4
- data.tar.gz: 4aafcf168d89410aae4e6f513a10e6fb3261c4a88493ca88125a1ea251cc6721
3
+ metadata.gz: 0e875308cd1e8bd154bb40ceccf856a1b91cfa74e1858c89b006735016980c10
4
+ data.tar.gz: 9306636667b1864c570bc467837e44e3d2490b965d4c3493ff1abe853832d419
5
5
  SHA512:
6
- metadata.gz: 67187df6dd956d5067b3a97f35fe53fbb35698f788c5a08f6fd6bf42cc20afcb910fab6773f8af24ac6d53d6f9bd0c23737e47d683921147e9453296d3eed32d
7
- data.tar.gz: 7ff5c7f235975206b17da9be813221c4d24e9706e036ef4fc32ed097cfa4ce52deeb11cd9acfc68c218b0e6b7f68d07a54155e81738acd3bf05ca26f75921f03
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
@@ -1,4 +1,4 @@
1
- Copyright (c) 2016-2019 Philip Ross
1
+ Copyright (c) 2016-2021 Philip Ross
2
2
 
3
3
  Permission is hereby granted, free of charge, to any person obtaining a copy of
4
4
  this software and associated documentation files (the "Software"), to deal in
data/README.md CHANGED
@@ -1,12 +1,13 @@
1
1
  # PuTTY::Key #
2
2
 
3
- [![Gem Version](https://badge.fury.io/rb/putty-key.svg)](https://badge.fury.io/rb/putty-key) [![Build Status](https://travis-ci.org/philr/putty-key.svg?branch=master)](https://travis-ci.org/philr/putty-key) [![Build status](https://ci.appveyor.com/api/projects/status/btinuu4g8sdachj3/branch/master?svg=true)](https://ci.appveyor.com/project/philr/tzinfo/branch/master) [![Coverage Status](https://coveralls.io/repos/philr/putty-key/badge.svg?branch=master)](https://coveralls.io/r/philr/putty-key?branch=master)
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 pure-Ruby implementation of the PuTTY private key (ppk) format,
6
- handling reading and writing .ppk files. It includes a refinement to Ruby's
7
- OpenSSL library to add support for converting DSA, EC and RSA private keys to
8
- and from PuTTY private key files. This allows OpenSSH ecdsa, ssh-dss and ssh-rsa
9
- private keys to be converted to and from PuTTY's private key format.
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 pure-Ruby implementation of the PuTTY private key (ppk)
5
- # format, handling reading and writing .ppk files. It includes a refinement to
6
- # Ruby's OpenSSL library to add support for converting DSA, EC and RSA private
7
- # keys to and from PuTTY private key files. This allows OpenSSH ecdsa, ssh-dss
8
- # and ssh-rsa private keys to be converted to and from PuTTY's private key
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
@@ -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 load a .ppk file.
10
+ # uninitialized key or to read a .ppk file (from file or an `IO`-like
11
+ # instance).
11
12
  #
12
- # The {#save} method can be used to save a {PPK} instance to a .ppk file.
13
+ # The {#save} method can be used to write a {PPK} instance to a .ppk file or
14
+ # `IO`-like instance.
13
15
  #
14
16
  # The {#algorithm}, {#comment}, {#public_blob} and {#private_blob}
15
17
  # attributes provide access to the high level fields of the PuTTY private
@@ -17,23 +19,45 @@ module PuTTY
17
19
  # based on the algorithm.
18
20
  #
19
21
  # Encrypted .ppk files can be read and written by specifying a passphrase
20
- # when loading or saving. As supported by PuTTY, files are always encrypted
21
- # using AES in CBC mode with a 256-bit key derived from the passphrase using
22
- # SHA-1.
22
+ # when loading or saving. Files are encrypted using AES in CBC mode with a
23
+ # 256-bit key derived from the passphrase.
23
24
  #
24
- # The {PPK} class supports files corresponding to PuTTY's format 2. Format 1
25
- # was only used briefly early on in the development of the .ppk format.
25
+ # The {PPK} class supports files corresponding to PuTTY's formats 2 and 3.
26
+ # Format 1 (not supported) was only used briefly early on in the development
27
+ # of the .ppk format and was never released. Format 2 is supported by PuTTY
28
+ # version 0.52 onwards. Format 3 is supported by PuTTY version 0.75 onwards.
29
+ # {PPK#save} defaults to format 2. Use the `format` parameter to select
30
+ # format 3.
31
+ #
32
+ # libargon2 (https://github.com/P-H-C/phc-winner-argon2) is required to load
33
+ # and save encrypted format 3 files. Binaries are typically available with
34
+ # your OS distribution. For Windows, binaries are available at
35
+ # https://github.com/philr/argon2-windows/releases - use either
36
+ # Argon2OptDll.dll for CPUs supporting AVX or Argon2RefDll.dll otherwise.
26
37
  class PPK
27
- # String used in the computation of the private MAC.
28
- MAC_KEY = 'putty-private-key-file-mac-key'
29
- private_constant :MAC_KEY
38
+ # String used in the computation of the format 3 MAC.
39
+ FORMAT_2_MAC_KEY = 'putty-private-key-file-mac-key'
40
+ private_constant :FORMAT_2_MAC_KEY
41
+
42
+ # Length of the key used for the format 3 MAC.
43
+ FORMAT_3_MAC_KEY_LENGTH = 32
44
+ private_constant :FORMAT_3_MAC_KEY_LENGTH
30
45
 
31
46
  # The default (and only supported) encryption algorithm.
32
47
  DEFAULT_ENCRYPTION_TYPE = 'aes256-cbc'.freeze
33
48
 
34
- # The default (and only supported) PuTTY private key file format.
49
+ # The default PuTTY private key file format.
35
50
  DEFAULT_FORMAT = 2
36
51
 
52
+ # The mimimum supported PuTTY private key file format.
53
+ MINIMUM_FORMAT = 2
54
+
55
+ # The maximum supported PuTTY private key file format.
56
+ MAXIMUM_FORMAT = 3
57
+
58
+ # Default Argon2 key derivation parameters for use with format 3.
59
+ DEFAULT_ARGON2_PARAMS = Argon2Params.new.freeze
60
+
37
61
  # The key's algorithm, for example, 'ssh-rsa' or 'ssh-dss'.
38
62
  #
39
63
  # @return [String] The key's algorithm, for example, 'ssh-rsa' or
@@ -59,70 +83,116 @@ module PuTTY
59
83
  # @return [String] The private component of the key
60
84
  attr_accessor :private_blob
61
85
 
62
- # Constructs a new {PPK} instance either uninitialized, or by loading a
63
- # .ppk file.
86
+ # Constructs a new {PPK} instance either uninitialized, or initialized
87
+ # by reading from a .ppk file or an `IO`-like instance.
88
+ #
89
+ # To read from a file set `path_or_io` to the file path, either as a
90
+ # `String` or a `Pathname`. To read from an `IO`-like instance set
91
+ # `path_or_io` to the instance. The instance must respond to `#read`.
92
+ # `#binmode` will be called before reading if supported by the instance.
64
93
  #
65
- # @param path [Object] Set to the path of a .ppk file to load the file.
66
- # Leave as `nil` to leave the new {PPK} instance uninitialized.
94
+ # @param path_or_io [Object] Set to the path of a .ppk file to load the
95
+ # file as a `String` or `Pathname`, or an `IO`-like instance to read the
96
+ # .ppk file from that instance. Set to `nil` to leave the new {PPK}
97
+ # instance uninitialized.
67
98
  # @param passphrase [String] The passphrase to use when loading an
68
99
  # encrypted .ppk file.
69
100
  #
70
101
  # @raise [Errno::ENOENT] If the file specified by `path` does not exist.
71
102
  # @raise [ArgumentError] If the .ppk file was encrypted, but either no
72
103
  # passphrase or an incorrect passphrase was supplied.
73
- # @raise [FormatError] If the .ppk file is malformed.
74
- def initialize(path = nil, passphrase = nil)
104
+ # @raise [FormatError] If the .ppk file is malformed or not supported.
105
+ # @raise [LoadError] If opening an encrypted format 3 .ppk file and
106
+ # libargon2 could not be loaded.
107
+ # @raise [Argon2Error] If opening an encrypted format 3 .ppk file and
108
+ # libargon2 reported an error hashing the passphrase.
109
+ def initialize(path_or_io = nil, passphrase = nil)
75
110
  passphrase = nil if passphrase && passphrase.to_s.empty?
76
111
 
77
- if path
78
- encryption_type, encrypted_private_blob, private_mac = Reader.open(path) do |reader|
79
- @algorithm = reader.field('PuTTY-User-Key-File-2')
112
+ if path_or_io
113
+ Reader.open(path_or_io) do |reader|
114
+ format, @algorithm = reader.field_matching(/PuTTY-User-Key-File-(\d+)/)
115
+ format = format.to_i
116
+ raise FormatError, "The ppk file is using a format that is too new (#{format})" if format > MAXIMUM_FORMAT
117
+ raise FormatError, "The ppk file is using an old unsupported format (#{format})" if format < MINIMUM_FORMAT
118
+
80
119
  encryption_type = reader.field('Encryption')
81
120
  @comment = reader.field('Comment')
82
121
  @public_blob = reader.blob('Public')
83
- encrypted_private_blob = reader.blob('Private')
84
- private_mac = reader.field('Private-MAC')
85
- [encryption_type, encrypted_private_blob, private_mac]
86
- end
87
122
 
88
- if encryption_type == 'none'
89
- passphrase = nil
90
- @private_blob = encrypted_private_blob
91
- else
92
- raise FormatError, "The ppk file is encrypted with #{encryption_type}, which is not supported" unless encryption_type == DEFAULT_ENCRYPTION_TYPE
93
- raise ArgumentError, 'The ppk file is encrypted, a passphrase must be supplied' unless passphrase
94
-
95
- # PuTTY uses a zero IV.
96
- cipher = ::OpenSSL::Cipher::AES.new(256, :CBC)
97
- cipher.decrypt
98
- cipher.key = generate_encryption_key(passphrase, cipher.key_len)
99
- cipher.padding = 0
100
- @private_blob = cipher.update(encrypted_private_blob) + cipher.final
101
- end
102
123
 
103
- expected_private_mac = compute_private_mac(passphrase, encryption_type, @private_blob)
124
+ if encryption_type == 'none'
125
+ passphrase = nil
126
+ mac_key = derive_keys(format).first
127
+ @private_blob = reader.blob('Private')
128
+ else
129
+ raise FormatError, "The ppk file is encrypted with #{encryption_type}, which is not supported" unless encryption_type == DEFAULT_ENCRYPTION_TYPE
130
+ raise ArgumentError, 'The ppk file is encrypted, a passphrase must be supplied' unless passphrase
131
+
132
+ argon2_params = if format >= 3
133
+ type = get_argon2_type(reader.field('Key-Derivation'))
134
+ memory = reader.unsigned_integer('Argon2-Memory', maximum: 2**32)
135
+ passes = reader.unsigned_integer('Argon2-Passes', maximum: 2**32)
136
+ parallelism = reader.unsigned_integer('Argon2-Parallelism', maximum: 2**32)
137
+ salt = reader.field('Argon2-Salt')
138
+ unless salt =~ /\A(?:[0-9a-fA-F]{2})+\z/
139
+ raise FormatError, "Expected the Argon2-Salt field to be a hex string, but found #{salt}"
140
+ end
141
+
142
+ Argon2Params.new(type: type, memory: memory, passes: passes, parallelism: parallelism, salt: [salt].pack('H*'))
143
+ end
144
+
145
+ cipher = ::OpenSSL::Cipher::AES.new(256, :CBC)
146
+ cipher.decrypt
147
+ mac_key, cipher.key, cipher.iv = derive_keys(format, cipher, passphrase, argon2_params)
148
+ cipher.padding = 0
149
+ encrypted_private_blob = reader.blob('Private')
150
+
151
+ @private_blob = if encrypted_private_blob.bytesize > 0
152
+ partial = cipher.update(encrypted_private_blob)
153
+ final = cipher.final
154
+ partial + final
155
+ else
156
+ encrypted_private_blob
157
+ end
158
+ end
104
159
 
105
- unless private_mac == expected_private_mac
106
- raise ArgumentError, 'Incorrect passphrase supplied' if passphrase
107
- raise FormatError, "Invalid Private MAC (expected #{expected_private_mac}, but found #{private_mac})"
160
+ private_mac = reader.field('Private-MAC')
161
+ expected_private_mac = compute_private_mac(format, mac_key, encryption_type, @private_blob)
162
+
163
+ unless private_mac == expected_private_mac
164
+ raise ArgumentError, 'Incorrect passphrase supplied' if passphrase
165
+ raise FormatError, "Invalid Private MAC (expected #{expected_private_mac}, but found #{private_mac})"
166
+ end
108
167
  end
109
168
  end
110
169
  end
111
170
 
112
- # Saves this PuTTY private key instance to a .ppk file.
171
+ # Writes this PuTTY private key instance to a .ppk file or `IO`-like
172
+ # instance.
173
+ #
174
+ # To write to a file, set `path_or_io` to the file path, either as a
175
+ # `String` or a `Pathname`. To write to an `IO`-like instance set
176
+ # `path_or_io` to the instance. The instance must respond to `#write`.
177
+ # `#binmode` will be called before writing if supported by the instance.
178
+ #
179
+ # If a file with the given path already exists, it will be overwritten.
113
180
  #
114
181
  # The {#algorithm}, {#private_blob} and {#public_blob} attributes must
115
182
  # have been set before calling {#save}.
116
183
  #
117
- # @param path [Object] The path to write to. If a file already exists, it
118
- # will be overwritten.
184
+ # @param path_or_io [Object] The path to write to as a `String` or
185
+ # `Pathname`, or an `IO`-like instance to write to.
119
186
  # @param passphrase [String] Set `passphrase` to encrypt the .ppk file
120
187
  # using the specified passphrase. Leave as `nil` to create an
121
188
  # unencrypted .ppk file.
122
189
  # @param encryption_type [String] The encryption algorithm to use.
123
190
  # Defaults to and currently only supports `'aes256-cbc'`.
124
191
  # @param format [Integer] The format of .ppk file to create. Defaults to
125
- # and currently only supports `2`.
192
+ # `2`. Supports `2` and `3`.
193
+ # @param argon2_params [Argon2Params] The parameters to use with Argon2
194
+ # to derive the encryption key, initialization vector and MAC key when
195
+ # saving an encrypted format 3 .ppk file.
126
196
  #
127
197
  # @return [Integer] The number of bytes written to the file.
128
198
  #
@@ -131,52 +201,73 @@ module PuTTY
131
201
  # @raise [ArgumentError] If `path` is nil.
132
202
  # @raise [ArgumentError] If a passphrase has been specified and
133
203
  # `encryption_type` is not `'aes256-cbc'`.
134
- # @raise [ArgumentError] If `format` is not `2`.
204
+ # @raise [ArgumentError] If `format` is not `2` or `3`.
205
+ # @raise [ArgumentError] If `argon2_params` is `nil`, a passphrase has
206
+ # been specified and `format` is `3`.
135
207
  # @raise [Errno::ENOENT] If a directory specified by `path` does not
136
208
  # exist.
137
- def save(path, passphrase = nil, encryption_type: DEFAULT_ENCRYPTION_TYPE, format: DEFAULT_FORMAT)
209
+ # @raise [LoadError] If saving an encrypted format 3 .ppk file and
210
+ # libargon2 could not be loaded.
211
+ # @raise [Argon2Error] If saving an encrypted format 3 .ppk file and
212
+ # libargon2 reported an error hashing the passphrase.
213
+ def save(path_or_io, passphrase = nil, encryption_type: DEFAULT_ENCRYPTION_TYPE, format: DEFAULT_FORMAT, argon2_params: DEFAULT_ARGON2_PARAMS)
138
214
  raise InvalidStateError, 'algorithm must be set before calling save' unless @algorithm
139
215
  raise InvalidStateError, 'public_blob must be set before calling save' unless @public_blob
140
216
  raise InvalidStateError, 'private_blob must be set before calling save' unless @private_blob
141
217
 
218
+ raise ArgumentError, 'An output path or io instance must be specified' unless path_or_io
219
+
142
220
  passphrase = nil if passphrase && passphrase.to_s.empty?
143
- encryption_type = 'none' unless passphrase
144
221
 
145
- raise ArgumentError, 'An output path must be specified' unless path
222
+ raise ArgumentError, 'A format must be specified' unless format
223
+ raise ArgumentError, "Unsupported format: #{format}" unless format >= MINIMUM_FORMAT && format <= MAXIMUM_FORMAT
146
224
 
147
225
  if passphrase
148
226
  raise ArgumentError, 'An encryption_type must be specified if a passphrase is specified' unless encryption_type
149
227
  raise ArgumentError, "Unsupported encryption_type: #{encryption_type}" unless encryption_type == DEFAULT_ENCRYPTION_TYPE
150
- end
151
-
152
- raise ArgumentError, 'A format must be specified' unless format
153
- raise ArgumentError, "Unsupported format: #{format}" unless format == DEFAULT_FORMAT
154
-
155
- padded_private_blob = @private_blob
228
+ raise ArgumentError, 'argon2_params must be specified if a passphrase is specified with format 3' unless format < 3 || argon2_params
156
229
 
157
- if passphrase
158
- # Pad using an SHA-1 hash of the unpadded private blob in order to
159
- # prevent an easily known plaintext attack on the last block.
160
230
  cipher = ::OpenSSL::Cipher::AES.new(256, :CBC)
161
231
  cipher.encrypt
162
- padding_length = cipher.block_size - (padded_private_blob.bytesize % cipher.block_size)
163
- padded_private_blob += ::OpenSSL::Digest::SHA1.new(padded_private_blob).digest[0, padding_length]
164
-
165
- # PuTTY uses a zero IV.
166
- cipher.key = generate_encryption_key(passphrase, cipher.key_len)
232
+ mac_key, cipher.key, cipher.iv, kdf_params = derive_keys(format, cipher, passphrase, argon2_params)
167
233
  cipher.padding = 0
168
- encrypted_private_blob = cipher.update(padded_private_blob) + cipher.final
234
+
235
+ # Pad using an SHA-1 hash of the unpadded private blob in order to
236
+ # prevent an easily known plaintext attack on the last block.
237
+ padding_length = cipher.block_size - ((@private_blob.bytesize - 1) % cipher.block_size) - 1
238
+ padded_private_blob = @private_blob
239
+ padded_private_blob += ::OpenSSL::Digest::SHA1.new(@private_blob).digest.byteslice(0, padding_length) if padding_length > 0
240
+
241
+ encrypted_private_blob = if padded_private_blob.bytesize > 0
242
+ partial = cipher.update(padded_private_blob)
243
+ final = cipher.final
244
+ partial + final
245
+ else
246
+ padded_private_blob
247
+ end
169
248
  else
170
- encrypted_private_blob = private_blob
249
+ encryption_type = 'none'
250
+ mac_key = derive_keys(format).first
251
+ kdf_params = nil
252
+ padded_private_blob = @private_blob
253
+ encrypted_private_blob = padded_private_blob
171
254
  end
172
255
 
173
- private_mac = compute_private_mac(passphrase, encryption_type, padded_private_blob)
256
+ private_mac = compute_private_mac(format, mac_key, encryption_type, padded_private_blob)
174
257
 
175
- Writer.open(path) do |writer|
176
- writer.field('PuTTY-User-Key-File-2', @algorithm)
258
+ Writer.open(path_or_io) do |writer|
259
+ writer.field("PuTTY-User-Key-File-#{format}", @algorithm)
177
260
  writer.field('Encryption', encryption_type)
178
261
  writer.field('Comment', @comment)
179
262
  writer.blob('Public', @public_blob)
263
+ if kdf_params
264
+ # Only Argon2 is currently supported.
265
+ writer.field('Key-Derivation', "Argon2#{kdf_params.type}")
266
+ writer.field('Argon2-Memory', kdf_params.memory)
267
+ writer.field('Argon2-Passes', kdf_params.passes)
268
+ writer.field('Argon2-Parallelism', kdf_params.parallelism)
269
+ writer.field('Argon2-Salt', kdf_params.salt.unpack('H*').first)
270
+ end
180
271
  writer.blob('Private', encrypted_private_blob)
181
272
  writer.field('Private-MAC', private_mac)
182
273
  end
@@ -184,13 +275,176 @@ module PuTTY
184
275
 
185
276
  private
186
277
 
187
- # Generates an encryption key of the specified length from a passphrase.
278
+ # Returns the Argon2 type (`:d`, `:i` or `:id`) corresponding to the value
279
+ # of the Key-Derivation field in the .ppk file.
280
+ #
281
+ # @param key_derivation [String] The value of the Key-Derivation field.
282
+ #
283
+ # @return [Symbol] The Argon2 type.
284
+ #
285
+ # @raise [FormatError] If `key_derivation` is unrecognized.
286
+ def get_argon2_type(key_derivation)
287
+ unless key_derivation =~ /\AArgon2(d|id?)\z/
288
+ raise FormatError, "Unrecognized key derivation type: #{key_derivation}"
289
+ end
290
+
291
+ $1.to_sym
292
+ end
293
+
294
+ # Derives the MAC key, encryption key and initialization vector from the
295
+ # passphrase (if the file is encrypted).
296
+ #
297
+ # @param format [Integer] The format of the .ppk file.
298
+ # @param cipher [OpenSSL::Cipher] The cipher being used to encrypt or
299
+ # decrypt the .ppk file or `nil` if not encrypted.
300
+ # @param passphrase [String] The passphrase used in the derivation or
301
+ # `nil` if the .ppk file is not encrypted. The raw bytes of the
302
+ # passphrase are used in the derivation.
303
+ # @param argon2_params [Argon2Params] Parameters used with the Argon2 hash
304
+ # function. May be `nil` if the .ppk file is not encrypted or `format`
305
+ # is less than 3.
306
+ #
307
+ # @return [Array<String, String, String, Argon2Params>] The MAC key,
308
+ # encryption key, initialization vector and final Argon2 parameters.
309
+ # The encryption key and initialization vector will be `nil` if `cipher`
310
+ # is `nil`. The final Argon2 parameters will only be set if `format` is
311
+ # greater than or equal to 3 and `cipher` is not nil. The final Argon2
312
+ # parameters will differ from `argon2_params` if the salt and passes
313
+ # options were left unspecified.
314
+ #
315
+ # @raise [LoadError] If `format` is at least 3, `cipher` is specified and
316
+ # libargon2 could not be loaded.
317
+ # @raise [Argon2Error] If `format` is at least 3, `cipher` is specified
318
+ # and libargon2 reported an error hashing the passphrase.
319
+ def derive_keys(format, cipher = nil, passphrase = nil, argon2_params = nil)
320
+ if format >= 3
321
+ return derive_format_3_keys(cipher, passphrase, argon2_params) if cipher
322
+ 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 generated key.
193
- def generate_encryption_key(passphrase, key_length)
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(passphrase, encryption_type, padded_private_blob)
223
- key = ::OpenSSL::Digest::SHA1.new
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(::OpenSSL::Digest::SHA1.new, key.digest, data)
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, creates a new instance of `Reader` and
235
- # yields it to the caller.
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 path [Object] The path of the .ppk file to be read.
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(path)
243
- File.open(path.to_s, 'rb') do |file|
244
- yield Reader.new(file)
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 {IO} to read from.
526
+ # Initializes a new {Reader} with an `IO`-like instance to read from.
249
527
  #
250
- # @param file [IO] The file to read from.
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 = field("#{name}-Lines")
280
- raise FormatError, "Invalid value for #{name}-Lines" unless lines =~ /\A\d+\z/
281
- lines.to_i.times.map { read_line }.join("\n").unpack('m48').first
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\n) terminated line from the file,
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
- @file.readline("\n").chomp("\n")
295
- rescue EOFError
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, creates a new instance of `Writer` and
311
- # yields it to the caller.
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 path [Object] The path of the .ppk file to be written.
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(path)
320
- File.open(path.to_s, 'wb') do |file|
321
- yield Writer.new(file)
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 {IO} to write to.
691
+ # Initializes a new {Writer} with an `IO`-like instance to write to.
326
692
  #
327
- # @param file [IO] The file to write to.
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 [String] The field 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 (\r\n on all platforms).
727
+ # Writes a line separator to the file (\n on all platforms).
362
728
  def write_line
363
- write("\r\n")
729
+ write("\n")
364
730
  end
365
731
 
366
732
  # Writes a string to the file.