├── .gitignore ├── History.txt ├── Manifest.txt ├── Rakefile ├── README.rdoc ├── test └── test_puttykey.rb └── lib └── puttykey.rb /.gitignore: -------------------------------------------------------------------------------- 1 | doc/ 2 | .autotest 3 | pkg/ 4 | -------------------------------------------------------------------------------- /History.txt: -------------------------------------------------------------------------------- 1 | === 1.0.0 / 2013-11-05 2 | 3 | * 1 major enhancement 4 | 5 | * Birthday! 6 | 7 | -------------------------------------------------------------------------------- /Manifest.txt: -------------------------------------------------------------------------------- 1 | .autotest 2 | History.txt 3 | Manifest.txt 4 | README.rdoc 5 | Rakefile 6 | lib/puttykey.rb 7 | test/test_puttykey.rb 8 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # -*- ruby -*- 2 | 3 | require 'rubygems' 4 | require 'hoe' 5 | 6 | Hoe.plugin :minitest 7 | 8 | spec = Hoe.spec 'puttykey' do 9 | developer "Lars Christensen", "larsch@belunktum.dk" 10 | license "MIt" 11 | end 12 | 13 | desc "Test packaged gem" 14 | task :package_test => :package do |x| 15 | pkg = "pkg/#{spec.name}-#{spec.version}" 16 | gem_path = Dir["#{pkg}*.gem"].first 17 | rm_rf "package_test" 18 | mkdir "package_test" 19 | chdir "package_test" do 20 | sh "tar", "xf", "../" + gem_path 21 | sh "tar", "xfz", "data.tar.gz" 22 | sh "ruby", "-S", "rake", "test" 23 | end 24 | rm_rf "package_test" 25 | end 26 | 27 | # vim: syntax=ruby 28 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | = puttykey 2 | 3 | * http://github.com/larsch/puttykey (url) 4 | 5 | == DESCRIPTION: 6 | 7 | Parse, format PuTTY private keys (.ppk) format. Convert PuTTY keys to 8 | SSL keys. 9 | 10 | == FEATURES/PROBLEMS: 11 | 12 | * Parse & decrypt PuTTY private keys (.ppk format) 13 | * Format PuTTY private keys (encryption supported) 14 | * Convert to/from OpenSSL::PKey::RSA 15 | 16 | == SYNOPSIS: 17 | 18 | PuttyKey.load filename, passphrase # => PuttyKey 19 | 20 | PuttyKey.parse string, passphrase # => PuttyKey 21 | 22 | putty_key.to_ppk(passphrase) # => String 23 | 24 | putty_key.to_openssl # => OpenSSL::PKey::RSA 25 | 26 | == Examples: 27 | 28 | Convert OpenSSH key to PuTTY format: 29 | 30 | ssh_key = OpenSSL::PKey::RSA.new(IO.read(filename)) 31 | putty_key = PuttyKey.new(key) 32 | ppk = putty_key.to_ppk(passphrase) 33 | 34 | Save authorized_keys public key from PPK file: 35 | 36 | putty_key = PuttyKey.load(ppk_filename, passphrase) 37 | ssh_key = putty_key.to_openssl 38 | authorized_keys = "#{ssh_key.type} #{ssh_key.to_blob} comment" 39 | 40 | Load Putty Key, requesting passphrase if required: 41 | 42 | require 'io/console' 43 | passphrase = nil 44 | begin 45 | PuttyPrivateKey.load(path, passphrase) 46 | rescue PuttyPrivateKey::DecryptError => e 47 | puts e.message 48 | IO.console.noecho do 49 | print "Passphrase: " 50 | passphrase = STDIN.gets.chomp 51 | puts 52 | end 53 | retry 54 | end 55 | 56 | == REQUIREMENTS: 57 | 58 | * None 59 | 60 | == INSTALL: 61 | 62 | * gem install puttykey 63 | 64 | == DEVELOPERS: 65 | 66 | After checking out the source, run: 67 | 68 | $ rake newb 69 | 70 | This task will install any missing dependencies, run the tests/specs, 71 | and generate the RDoc. 72 | 73 | == LICENSE: 74 | 75 | (The MIT License) 76 | 77 | Copyright (c) 2013 Lars Christensen 78 | 79 | Permission is hereby granted, free of charge, to any person obtaining 80 | a copy of this software and associated documentation files (the 81 | 'Software'), to deal in the Software without restriction, including 82 | without limitation the rights to use, copy, modify, merge, publish, 83 | distribute, sublicense, and/or sell copies of the Software, and to 84 | permit persons to whom the Software is furnished to do so, subject to 85 | the following conditions: 86 | 87 | The above copyright notice and this permission notice shall be 88 | included in all copies or substantial portions of the Software. 89 | 90 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 91 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 92 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 93 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 94 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 95 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 96 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 97 | -------------------------------------------------------------------------------- /test/test_puttykey.rb: -------------------------------------------------------------------------------- 1 | require "puttykey" 2 | require "openssl" 3 | 4 | class TestPuttykey < Minitest::Test 5 | def reference 6 | @reference ||= OpenSSL::PKey.read(SSH_REFERENCE) 7 | end 8 | 9 | def assert_reference(key) 10 | assert key.kind_of?(PuttyKey) 11 | ssl = key.to_openssl 12 | assert_equal_ssl_rsa reference, ssl 13 | end 14 | 15 | def assert_equal_ssl_rsa(reference, ssl) 16 | assert_equal reference.public_key.e, ssl.public_key.e 17 | assert_equal reference.n, ssl.n 18 | assert_equal reference.p, ssl.p 19 | assert_equal reference.q, ssl.q 20 | assert_equal reference.d, ssl.d 21 | assert_equal reference.dmp1, ssl.dmp1 22 | assert_equal reference.dmq1, ssl.dmq1 23 | assert_equal reference.iqmp, ssl.iqmp 24 | end 25 | 26 | def test_parse 27 | assert_reference PuttyKey.parse(PPK_CLEAR) 28 | end 29 | 30 | def test_parse_decrypt 31 | assert_reference PuttyKey.parse(PPK_ENCRYPTED, PPK_PASSPHRASE) 32 | end 33 | 34 | def test_from_openssl 35 | assert_reference PuttyKey.new(reference) 36 | end 37 | 38 | def test_to_ppk_clear 39 | key = PuttyKey.new(reference) 40 | key.comment = "imported-openssh-key" 41 | assert_equal PPK_CLEAR, key.to_ppk 42 | end 43 | 44 | def test_encrypt_from_openssl 45 | key = PuttyKey.new(reference) 46 | ppk_encrypted = key.to_ppk(PPK_PASSPHRASE) 47 | parsed = PuttyKey.parse(ppk_encrypted, PPK_PASSPHRASE) 48 | assert_reference parsed 49 | end 50 | 51 | def test_generate 52 | openssl_key = OpenSSL::PKey::RSA.generate(128) 53 | ppk = PuttyKey.new(openssl_key) 54 | text = ppk.to_ppk(PPK_PASSPHRASE) 55 | ppk = PuttyKey.new(text, PPK_PASSPHRASE) 56 | assert_equal_ssl_rsa openssl_key, ppk.to_openssl 57 | end 58 | 59 | end 60 | 61 | SSH_REFERENCE = "-----BEGIN RSA PRIVATE KEY----- 62 | MIIEpAIBAAKCAQEAxulYoU4TFxLO1Wg3hVlOtamFIQsbM3na9qM4GhSVgwgjuwpJ 63 | B2Herz6Lx89yXsLKvQYE+e+Zm7yeM7l92pa6swbMcy7W2kQHIOZB03VrazMT23+v 64 | 2cVpPZdS7MqX0SXu8VNmACDVM0+E36UcuG5CjJp1m9rMVkudqn0vMTcqX3K13007 65 | PrTd5pSNf1KbhYab5ZuaceL0v4jYi99KYXhjmmUjA2gG8pxW6uCQA1gMI7/TOYId 66 | H5X8d/BkEHvWkiKvyDUZ28yWKjfurCDEeDOCeJdPTsZVT4QTM3+WK+L+qfdBXc57 67 | 2dZuSYEMdeaH7lROA+dZqqQy0L+2lE84ZXtO5QIDAQABAoIBAQCrQ8dYO80cFMmZ 68 | 3f3QBzFKIQfLh7CIBeeObMKlUgvZomyBYz216YK/CO95vxgOl1HQpxopyS9NdH4S 69 | sye1ygo+kx/+HNpJXEF3BkqvM26mAniaibpzmxIeQejYkSdeoXa2usQcYCix4Una 70 | 9mNgOS97uJKC+0TtGHZMkTTM/16whqMtZ1iJ3tp4ONB80+sScNs3e8U1aU0BipwS 71 | 9e+/KUjprTPq4ZDaAhB2ZIJkoivNGAuNmHHeitxm80Wk+mB7HJHuZMsnEJSXfDVX 72 | yZ1HUYXBXS/kCAl2lXAUvJ2QoR6J4NMIVVv8RR7YDOTFlfBVFPYFEW8VNrvppYZc 73 | EHtd7eMRAoGBAOwMxWEAihr//MTPhYI8LYD2Yboojm2T/pQVPBe3AUvCYkIC/14x 74 | oTKtWsaR61G1hTqHQ0q0LLvlkuCcioujrxGmRA+GgsGTSaaPYGqv5yl2+jDNlrUO 75 | HT+I2nMOg+p+GqD7slqWCqGI+UpRtJqNYgcIcq4M15B4zv7OnlCkwg9vAoGBANe5 76 | CnKi0Ct8eO5Hp1Ev0Mb8HXRqSrxdPx0BMvJB0ycS6CjFzizB1r7eds/hTGyxLIeT 77 | gR2NhjOWoZ3WaH5tClw6MReitG11szD07GOBRrecnLt9LQYxi9EGgIrWYuATZww+ 78 | blpGfrcdkj5dWIkRAL6ZIz533SSQlroNMg9pSRzrAoGBAOlgmwkTuneFXkjLkAk6 79 | LBcUAX1HOcIXDx0jfX1I30wizHjNc+OSF/j9sgEfJdRsLmO2dg524r+G89eEjeoP 80 | lDhT9XiQGdj/IVM+8Cmq7lZtnmD/8p/ha4N0b95PnJcLxJIjJ6wuKiaZQTd8Xp5r 81 | aF7huFhitAHPn4AHkjjTHFabAoGARgahJ5lGbfdX4jGMVMRqx00r2pBudjrms+mh 82 | uhY4DuUKS8H6LXk21nqsosqF3nqc892j+g3o1HI/QFdLUE7hIBMbwIpme2nLo0a+ 83 | PYbHh+7kyc/Wf74xnsa3j1oMeqSRvN2/QLrFg3er82alyMimLzjSwgJy3N26r+Z8 84 | q5gHzcUCgYADeyPRPNZkng+7wdqQpcgkDgshzfgH59KhPbgtWzFwMD4Q0s2L9YWY 85 | AaT3Tipypb4FNm/QwFjUuNxwsGJZZVvrNVJTrzF1Fq/33hIqLI5mCEFvLwey3toc 86 | 8pZbbXJwXVliimEkItD1UI/7nbTVW0iuJUAirMDTHMbuuWj2mQMnKA== 87 | -----END RSA PRIVATE KEY----- " 88 | 89 | PPK_CLEAR = "PuTTY-User-Key-File-2: ssh-rsa 90 | Encryption: none 91 | Comment: imported-openssh-key 92 | Public-Lines: 6 93 | AAAAB3NzaC1yc2EAAAADAQABAAABAQDG6VihThMXEs7VaDeFWU61qYUhCxszedr2 94 | ozgaFJWDCCO7CkkHYd6vPovHz3Jewsq9BgT575mbvJ4zuX3alrqzBsxzLtbaRAcg 95 | 5kHTdWtrMxPbf6/ZxWk9l1LsypfRJe7xU2YAINUzT4TfpRy4bkKMmnWb2sxWS52q 96 | fS8xNypfcrXfTTs+tN3mlI1/UpuFhpvlm5px4vS/iNiL30pheGOaZSMDaAbynFbq 97 | 4JADWAwjv9M5gh0flfx38GQQe9aSIq/INRnbzJYqN+6sIMR4M4J4l09OxlVPhBMz 98 | f5Yr4v6p90FdznvZ1m5JgQx15ofuVE4D51mqpDLQv7aUTzhle07l 99 | Private-Lines: 14 100 | AAABAQCrQ8dYO80cFMmZ3f3QBzFKIQfLh7CIBeeObMKlUgvZomyBYz216YK/CO95 101 | vxgOl1HQpxopyS9NdH4Ssye1ygo+kx/+HNpJXEF3BkqvM26mAniaibpzmxIeQejY 102 | kSdeoXa2usQcYCix4Una9mNgOS97uJKC+0TtGHZMkTTM/16whqMtZ1iJ3tp4ONB8 103 | 0+sScNs3e8U1aU0BipwS9e+/KUjprTPq4ZDaAhB2ZIJkoivNGAuNmHHeitxm80Wk 104 | +mB7HJHuZMsnEJSXfDVXyZ1HUYXBXS/kCAl2lXAUvJ2QoR6J4NMIVVv8RR7YDOTF 105 | lfBVFPYFEW8VNrvppYZcEHtd7eMRAAAAgQDsDMVhAIoa//zEz4WCPC2A9mG6KI5t 106 | k/6UFTwXtwFLwmJCAv9eMaEyrVrGketRtYU6h0NKtCy75ZLgnIqLo68RpkQPhoLB 107 | k0mmj2Bqr+cpdvowzZa1Dh0/iNpzDoPqfhqg+7JalgqhiPlKUbSajWIHCHKuDNeQ 108 | eM7+zp5QpMIPbwAAAIEA17kKcqLQK3x47kenUS/QxvwddGpKvF0/HQEy8kHTJxLo 109 | KMXOLMHWvt52z+FMbLEsh5OBHY2GM5ahndZofm0KXDoxF6K0bXWzMPTsY4FGt5yc 110 | u30tBjGL0QaAitZi4BNnDD5uWkZ+tx2SPl1YiREAvpkjPnfdJJCWug0yD2lJHOsA 111 | AACAA3sj0TzWZJ4Pu8HakKXIJA4LIc34B+fSoT24LVsxcDA+ENLNi/WFmAGk904q 112 | cqW+BTZv0MBY1LjccLBiWWVb6zVSU68xdRav994SKiyOZghBby8Hst7aHPKWW21y 113 | cF1ZYophJCLQ9VCP+5201VtIriVAIqzA0xzG7rlo9pkDJyg= 114 | Private-MAC: 0c33023ee5155968d30088bde5e89dd5af23b688 115 | " 116 | 117 | PPK_ENCRYPTED = "PuTTY-User-Key-File-2: ssh-rsa 118 | Encryption: aes256-cbc 119 | Comment: imported-openssh-key 120 | Public-Lines: 6 121 | AAAAB3NzaC1yc2EAAAADAQABAAABAQDG6VihThMXEs7VaDeFWU61qYUhCxszedr2 122 | ozgaFJWDCCO7CkkHYd6vPovHz3Jewsq9BgT575mbvJ4zuX3alrqzBsxzLtbaRAcg 123 | 5kHTdWtrMxPbf6/ZxWk9l1LsypfRJe7xU2YAINUzT4TfpRy4bkKMmnWb2sxWS52q 124 | fS8xNypfcrXfTTs+tN3mlI1/UpuFhpvlm5px4vS/iNiL30pheGOaZSMDaAbynFbq 125 | 4JADWAwjv9M5gh0flfx38GQQe9aSIq/INRnbzJYqN+6sIMR4M4J4l09OxlVPhBMz 126 | f5Yr4v6p90FdznvZ1m5JgQx15ofuVE4D51mqpDLQv7aUTzhle07l 127 | Private-Lines: 14 128 | D4ZSGsZwN5j1mpSIh464KYzyNGRQaL3ySP7WdKRFT9k4uUFt7F+3KGO8kxsRjqcf 129 | qZMDe7E+aRlF/ylFDJkoiaQF54bcDaRaYaihBds/ZhW2N7ja19Mf0cDNp3eGMqaH 130 | JCiAwWyWRl+5jYQOFSD17oEi++sWi5ni7IiEoXx4I6R42E06xgSCyRcdgt3wXQfr 131 | myOCOwxvpRINxiG1v7qKSlfDVyuOGBduTVSXa5br5gSqPOeRp9b1Dyy0Q4asDvzF 132 | n0PrkFEj9GSGCF9y803rVUtVcYIfVvjX3E07R/spIsO+JW7e0MVt+r66KTKL5LUU 133 | 7zsSuPLUrwrewI0I4OuWQNDgnNmQsk47rCg4mGzz/P14MaefAkZ5U3OWJDCg6O76 134 | zBMzVyXre1s64mGjYZdfbfwGiaib0TTdAHwZSBBuYBzmQN9aJa+CfQ/zctmYWvsa 135 | /JEu608sv7H4HngAo7tku6d5fAEuY4dS9gvZ2WARWAcp00sBIIRw9B3y019udEyB 136 | AuAtnJ3TaxbYuZq6ZhV3Xtjp9uV49cYxgrfO/djrO9CFMk98lMX7VmiH4P/CHPbu 137 | aGW5tIUJ2PB/L4mXjjwecqqGAteCKjFqvE6cdcEPZlVtgZhBejX4TXJlA4vQvDb5 138 | 11Q7mwr94TriVrV+Jy57Dje8CufrWktCbfXp3AhfZEqpeSszs5RrNrNniTsasoVV 139 | 9IaXt/rvy7H4iCZOKJU4w0uYzdF0zhrjdD4i4oqA26padvVbyPe43BZCa1F8wI8E 140 | +GiEG35lTXAb6dat3VmrD7FyYrs430AQg1AFVKbXBJ00RGKBXYWfsnB4S10HdU3C 141 | rxgZVVq6KPFH9ZwQemxWrg8EhxUJt3tWd07qpflsnbnaxGMy5VIIFoGp23LGjpFa 142 | Private-MAC: 865dc67d9192307d80aa6847ac7aa53475562e2d 143 | " 144 | 145 | PPK_PASSPHRASE = "asdf" 146 | -------------------------------------------------------------------------------- /lib/puttykey.rb: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | require 'openssl' 3 | 4 | class PuttyKey 5 | 6 | VERSION = "0.1.0" 7 | 8 | # Exception raises if decryption fails due to missing or invalid 9 | # passphrase. 10 | class DecryptError < RuntimeError 11 | end 12 | 13 | # Load a PuTTY private key from a file, with an optional 14 | # passphrase. Raises DecryptError if passphrase is missing or 15 | # incorrect. 16 | def self.load(filename, passphrase = nil) 17 | self.new(IO.read(filename), passphrase) 18 | end 19 | 20 | # Parse a PuTTY private key from a string, with an optional 21 | # passphrase. Raises DecryptError if passphrase is missing or 22 | # incorrect. 23 | def self.parse(string, passphrase = nil) 24 | self.new(string, passphrase) 25 | end 26 | 27 | # The SSH key comment. Can be set before calling to_ppk. Not 28 | # preserved when converting to OpenSSL key. 29 | attr_accessor :comment 30 | 31 | # The key type (ssh-rsa). 32 | attr_reader :name 33 | 34 | # Initialize the PuTTY key from either a ppk formatted string, or a 35 | # OpenSSL::PKey::RSA instance. 36 | def initialize(key = nil, passphrase = nil) 37 | @encryption = "none" 38 | case key 39 | when OpenSSL::PKey::RSA 40 | raise ArgumentError, "Expected only 1 argument" if passphrase 41 | @name = "ssh-rsa" 42 | @comment = "OpenSSL RSA Key" 43 | @exponent = key.public_key.e.to_s(2) 44 | @modulus = "\x00" + key.public_key.n.to_s(2) 45 | @private_exponent = "\x00" + key.d.to_s(2) 46 | @pk_q = "\x00" + key.q.to_s(2) 47 | @pk_p = "\x00" + key.p.to_s(2) 48 | @iqmp = key.iqmp.to_s(2) 49 | when String 50 | parse(key, passphrase) 51 | when NilClass 52 | else 53 | raise ArgumentError, "Unexpected argument type #{from.class.name}" 54 | end 55 | end 56 | 57 | # Convert the private key to PPK format, optionally protected by a 58 | # passphrase. 59 | def to_ppk(passphrase = nil) 60 | if passphrase 61 | encrypt passphrase 62 | priv_blob = @private_blob_encrypted 63 | else 64 | passphrase = "" 65 | @encryption = "none" 66 | priv_blob = private_blob 67 | end 68 | 69 | public_key_lines = [public_blob].pack("m0").gsub(/(.{1,64})/, "\\1\n") 70 | private_key_lines = [priv_blob].pack("m0").gsub(/(.{1,64})/, "\\1\n") 71 | 72 | mac = private_mac(passphrase) 73 | mac_hex = mac.unpack("H*").first 74 | 75 | "PuTTY-User-Key-File-2: #{@name}\n" + 76 | "Encryption: #{@encryption}\n" + 77 | "Comment: #{@comment}\n" + 78 | "Public-Lines: #{public_key_lines.lines.count}\n" + 79 | public_key_lines + 80 | "Private-Lines: #{private_key_lines.lines.count}\n" + 81 | private_key_lines + 82 | "Private-MAC: #{mac_hex}\n" 83 | end 84 | 85 | # Convert the key to an OpenSSL::PKey::RSA 86 | def to_openssl 87 | key = OpenSSL::PKey::RSA.new 88 | key.e = OpenSSL::BN.new(@exponent, 2) 89 | key.n = OpenSSL::BN.new(@modulus, 2) 90 | key.q = OpenSSL::BN.new(@pk_q, 2) 91 | key.p = OpenSSL::BN.new(@pk_p, 2) 92 | key.iqmp = OpenSSL::BN.new(@iqmp, 2) 93 | key.d = OpenSSL::BN.new(@private_exponent, 2) 94 | key.dmp1 = key.d % (key.p - 1) 95 | key.dmq1 = key.d % (key.q - 1) 96 | return key 97 | end 98 | 99 | private 100 | 101 | def encrypted? 102 | @encryption != "none" 103 | end 104 | 105 | def decrypt(passphrase) 106 | case @encryption 107 | when "aes256-cbc" 108 | raise DecryptError, "Passphrase required" if passphrase.nil? 109 | cipher = OpenSSL::Cipher.new("AES-256-CBC") 110 | cipher.decrypt 111 | cipher.padding = 0 112 | key = Digest::SHA1.digest("\0\0\0\0" + passphrase) + Digest::SHA1.digest("\0\0\0\1" + passphrase) 113 | cipher.key = key[0, 32] 114 | cipher.iv = "\0" * cipher.iv_len 115 | @private_blob = cipher.update(@private_blob_encrypted) + cipher.final 116 | mac = private_mac(passphrase) 117 | raise DecryptError, "Failed to decrypt" unless mac == @private_mac 118 | unpack_private_blob 119 | when "none" 120 | @private_blob = @private_blob_encrypted 121 | end 122 | end 123 | 124 | def private_blob 125 | @private_blob ||= [ @private_exponent.length, @private_exponent, @pk_p.length, @pk_p, @pk_q.length, @pk_q, @iqmp.length, @iqmp ].pack("Na*Na*Na*Na*") 126 | end 127 | 128 | def public_blob 129 | @public_blob ||= [ @name.length, @name, @exponent.length, @exponent, @modulus.length, @modulus ].pack("Na*Na*Na*") 130 | end 131 | 132 | def mac_blob 133 | @mac_blob ||= [ @name.length, @name, 134 | @encryption.length, @encryption, 135 | @comment.length, @comment, 136 | @public_blob.length, @public_blob, 137 | @private_blob.length, @private_blob ].pack("Na*Na*Na*Na*Na*") 138 | end 139 | 140 | def encrypt(passphrase) 141 | @encryption = "aes256-cbc" 142 | 143 | cipher_key = Digest::SHA1.digest("\0\0\0\0" + passphrase) + Digest::SHA1.digest("\0\0\0\1" + passphrase) 144 | cipher = OpenSSL::Cipher.new("AES-256-CBC") 145 | cipher.encrypt 146 | cipher.padding = 0 147 | cipher.key = cipher_key[0, cipher.key_len] 148 | cipher.iv = "\0" * cipher.iv_len 149 | 150 | blob = private_blob 151 | block_size = cipher.block_size 152 | final_size = ((blob.size + block_size - 1) / block_size) * block_size 153 | missing = final_size - blob.size 154 | blob << Digest::SHA1.digest(blob)[0, missing] 155 | 156 | @private_blob_encrypted = cipher.update(blob) + cipher.final 157 | 158 | self 159 | end 160 | 161 | def load(filename) 162 | parse(IO.read(filename)) 163 | end 164 | 165 | def parse(string, passphrase = nil) 166 | lns = string.lines.to_a 167 | until lns.empty? 168 | if lns.shift =~ /(\S+): (.*)/ 169 | key, value = $1, $2 170 | case key 171 | when "PuTTY-User-Key-File-2" 172 | @name = value 173 | when "Encryption" 174 | @encryption = value 175 | when "Comment" 176 | @comment = value 177 | when "Public-Lines" 178 | @public_blob = lns.shift(value.to_i).join.unpack("m").first 179 | when "Private-Lines" 180 | @private_blob_encrypted = lns.shift(value.to_i).join.unpack("m").first 181 | @private_blob = @private_blob_encrypted unless encrypted? 182 | when "Private-MAC" 183 | @private_mac = [value].pack("H*") 184 | end 185 | end 186 | end 187 | unpack_public_blob 188 | decrypt passphrase if encrypted? 189 | unpack_private_blob 190 | self 191 | end 192 | 193 | def private_mac(passphrase) 194 | mac_key = Digest::SHA1.digest("putty-private-key-file-mac-key" + passphrase) 195 | hmac_sha1_simple(mac_key, mac_blob) 196 | end 197 | 198 | def unpack_public_blob 199 | s = @public_blob 200 | name_length, s = s.unpack("Na*") 201 | @name, exponent_length, s = s.unpack("a#{name_length}Na*") 202 | @exponent, modulus_length, s = s.unpack("a#{exponent_length}Na*") 203 | @modulus, s = s.unpack("a#{modulus_length}") 204 | end 205 | 206 | def unpack_private_blob 207 | s = @private_blob 208 | private_exponent_length, s = s.unpack("Na*") 209 | @private_exponent, pk_p_length, s = s.unpack("a#{private_exponent_length}Na*") 210 | @pk_p, pk_q_length, s = s.unpack("a#{pk_p_length}Na*") 211 | @pk_q, iqmp_length, s = s.unpack("a#{pk_q_length}Na*") 212 | @iqmp, _ = s.unpack("a#{iqmp_length}a*") 213 | end 214 | 215 | def hmac_sha1_simple(key, data) 216 | OpenSSL::HMAC.digest(OpenSSL::Digest.new("SHA1"), key, data) 217 | end 218 | 219 | end 220 | --------------------------------------------------------------------------------