trezor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,25 @@
1
+ require 'net/ssh/authentication/methods/publickey'
2
+
3
+ require 'trezor/key_manager'
4
+
5
+ module Net
6
+ module SSH
7
+ module Authentication
8
+ module Methods
9
+
10
+ # Usage:
11
+ #
12
+ # Net::SSH.start("host", "user", auth_methods: %w[publickey-trezor], keys: %w[~/.ssh/config]) do |ssh|
13
+ # result = ssh.exec!("ls -l")
14
+ # puts result
15
+ # end
16
+ class PublickeyTrezor < Publickey
17
+ def initialize(session, options = {})
18
+ super
19
+ @key_manager = Trezor::KeyManager.new(@key_manager.options[:keys])
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
data/lib/trezor.rb ADDED
@@ -0,0 +1,2 @@
1
+ module Trezor
2
+ end
@@ -0,0 +1,88 @@
1
+ require 'base64'
2
+ require 'socket'
3
+
4
+ require 'net/ssh/authentication/agent'
5
+
6
+ require 'trezor/utils'
7
+ require 'trezor/key_manager'
8
+
9
+ module Trezor
10
+ class Agent
11
+ include Utils
12
+
13
+ MAPPING = {
14
+ Net::SSH::Authentication::Agent::SSH2_AGENT_REQUEST_VERSION => :send_version,
15
+ Net::SSH::Authentication::Agent::SSH2_AGENT_REQUEST_IDENTITIES => :list_identities,
16
+ Net::SSH::Authentication::Agent::SSH2_AGENT_SIGN_REQUEST => :sign_challenge
17
+ }
18
+
19
+ def initialize(socket_path:, key_manager:)
20
+ @socket_path = socket_path
21
+ @key_manager = key_manager
22
+ end
23
+
24
+ def server
25
+ @server ||= UNIXServer.new(@socket_path)
26
+ end
27
+
28
+ def on_request
29
+ socket = server.accept
30
+ Thread.new do
31
+ yield(socket)
32
+ socket.close
33
+ end
34
+ end
35
+
36
+ def run
37
+ while true
38
+ on_request do |socket|
39
+ while true
40
+ type, body = read_packet(socket)
41
+ break unless method_name = MAPPING[type]
42
+ response = send(method_name, body)
43
+ socket.write(Buffer.from(:string, response.to_s))
44
+ end
45
+ end
46
+ end
47
+ ensure
48
+ File.unlink(@socket_path) if File.exists?(@socket_path)
49
+ end
50
+
51
+ def send_version(body)
52
+ # Net::SSH::Authentication::Agent::SSH2_AGENT_VERSION_RESPONSE
53
+ buffer = body.read
54
+ Buffer.from(:byte, 2, :long, 0)
55
+ end
56
+
57
+ def list_identities(body)
58
+ Buffer.from(
59
+ :byte, Net::SSH::Authentication::Agent::SSH2_AGENT_IDENTITIES_ANSWER,
60
+ :long, @key_manager.identities.count,
61
+ :string,
62
+ @key_manager.map { |i| [i.key.to_blob, i.key_name] }.flatten
63
+ )
64
+ end
65
+
66
+ def failure_message
67
+ Buffer.from(:byte, Net::SSH::Authentication::Agent::SSH_AGENT_FAILURE)
68
+ end
69
+
70
+ def sign_challenge(body)
71
+ server_key = body.read_buffer.read_key
72
+ blob = body.read_buffer
73
+ return failure_message unless signature = @key_manager.sign(server_key, blob)
74
+ Buffer.from(
75
+ :byte, Net::SSH::Authentication::Agent::SSH2_AGENT_SIGN_RESPONSE,
76
+ :string, signature
77
+ )
78
+ end
79
+
80
+ def read_packet(socket)
81
+ buffer = Buffer.new(socket.read(4))
82
+ buffer.append(socket.read(buffer.read_long))
83
+ type = buffer.read_byte
84
+ #debug { "received agent packet #{type} len #{buffer.length - 4}" }
85
+ return type, buffer
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,141 @@
1
+ require 'io/console'
2
+
3
+ require 'http'
4
+
5
+ require 'trezor/utils'
6
+ require 'trezor/protobuf'
7
+
8
+ module Trezor
9
+ class Device
10
+ include Utils
11
+
12
+ HOST = 'http://127.0.0.1:21325'
13
+ HEADERS = { origin: 'https://python.trezor.io' }
14
+
15
+ def initialize(device_info)
16
+ @session_counter = 0
17
+ @device_info = device_info
18
+ @connection = HTTP.persistent(HOST)
19
+ @connection.default_options.headers.merge!(HEADERS)
20
+ end
21
+
22
+ def start_session
23
+ acquire! if @session_counter == 0
24
+ @session_counter += 1
25
+ end
26
+
27
+ def end_session
28
+ @session_counter -= 1
29
+ release! if @session_counter == 0
30
+ end
31
+
32
+ def acquire!
33
+ response = @connection.post("/acquire/#{@device_info['path']}/null")
34
+ body = response.to_s
35
+ raise body unless response.status.ok?
36
+ @session = JSON.parse(body)['session']
37
+ end
38
+
39
+ def release!
40
+ return unless @session
41
+ response = @connection.post("/release/#{@session}")
42
+ body = response.to_s
43
+ raise body unless response.status.ok?
44
+ @session = nil
45
+ end
46
+
47
+ def call(message)
48
+ response = post(message)
49
+ handler = "callback_#{response.class.name.demodulize}"
50
+ unless respond_to?(handler)
51
+ #puts "could not find handler for #{response.class.name}"
52
+ return response
53
+ end
54
+ call(send(handler, response))
55
+ end
56
+
57
+ def post(message)
58
+ serialized = Protobuf.serialize(message)
59
+ response = @connection.post("/call/#{@session}", body: serialized)
60
+ raise response.to_s unless response.status.ok?
61
+ Protobuf.decode(response.to_s)
62
+ end
63
+
64
+ def method_missing(method_name, *args, &block)
65
+ return super unless respond_to?(method_name)
66
+ call(Protobuf[method_name].new(*args), &block)
67
+ end
68
+
69
+ def respond_to_missing?(method_name, include_private = false)
70
+ super || Protobuf[method_name].present?
71
+ end
72
+
73
+ def callback_ButtonRequest(msg)
74
+ Protobuf::ButtonAck.new
75
+ end
76
+
77
+ def callback_PassphraseRequest(msg)
78
+ return Protobuf::PassphraseAck.new if msg.on_device
79
+ passphrase = ask('Enter passphrase:', false).unicode_normalize(:nfkd)
80
+ Protobuf::PassphraseAck.new(passphrase: passphrase)
81
+ end
82
+
83
+ def callback_PassphraseStateRequest(msg)
84
+ Protobuf::PassphraseStateAck.new
85
+ end
86
+
87
+ def callback_PinMatrixRequest(msg)
88
+ prompt =
89
+ case msg.type
90
+ when Protobuf::PinMatrixRequestType::PinMatrixRequestType_Current
91
+ 'current PIN'
92
+ when Protobuf::PinMatrixRequestType::PinMatrixRequestType_NewFirst
93
+ 'new PIN'
94
+ when Protobuf::PinMatrixRequestType::PinMatrixRequestType_NewSecond
95
+ 'new PIN again'
96
+ else
97
+ 'PIN'
98
+ end
99
+ Protobuf::PinMatrixAck.new(pin: ask("Enter #{prompt}:", false))
100
+ end
101
+
102
+ def self.enumerate
103
+ JSON.parse(HTTP[HEADERS].post("#{HOST}/enumerate").to_s).map do |device_info|
104
+ new(device_info)
105
+ end
106
+ end
107
+
108
+ MUTEX = Mutex.new
109
+ def self.with_session
110
+ MUTEX.lock
111
+ device = enumerate.first
112
+ unless device
113
+ # log debug message 'Trezor not found'
114
+ return
115
+ end
116
+ device.start_session
117
+ yield(device)
118
+ ensure
119
+ device.end_session if device
120
+ MUTEX.unlock
121
+ end
122
+
123
+ def self.prompter(&block)
124
+ return @default_prompter = lambda(&block) if block_given?
125
+ prompter do |prompt, echo|
126
+ STDOUT.print(prompt)
127
+ if echo
128
+ STDIN.gets
129
+ else
130
+ STDIN.noecho(&:gets)
131
+ end.chomp.tap { STDOUT.print("\n") }
132
+ end
133
+ end
134
+
135
+ private
136
+
137
+ def ask(prompt, echo = true)
138
+ self.class.prompter.call(prompt, echo)
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,102 @@
1
+ require 'uri'
2
+
3
+ require 'openssl'
4
+
5
+ require 'trezor/device'
6
+ require 'trezor/utils'
7
+
8
+ module Trezor
9
+ class Identity
10
+ extend Forwardable
11
+ include Utils
12
+
13
+ # Supported ECDSA curves for SSH (GPG not implemented)
14
+ CURVE_NIST256 = 'nist256p1'
15
+ CURVE_ED25519 = 'ed25519'
16
+
17
+ # Supported ECDH curves for GPG (not implemented)
18
+ ECDH_NIST256 = 'nist256p1'
19
+ ECDH_CURVE25519 = 'curve25519'
20
+
21
+ SSH_NIST256_KEY_PREFIX = 'ecdsa-sha2-'
22
+ SSH_NIST256_CURVE_NAME = 'nistp256'
23
+ SSH_NIST256_KEY_TYPE = SSH_NIST256_KEY_PREFIX + SSH_NIST256_CURVE_NAME
24
+ SSH_ED25519_KEY_TYPE = 'ssh-ed25519'
25
+
26
+ delegate [:user, :host, :port, :path] => :@uri
27
+ def_delegator :@uri, :scheme, :proto
28
+ attr_accessor :curve_name
29
+
30
+ def initialize(identity_string, curve_name = CURVE_NIST256, key = nil)
31
+ @uri = URI.parse(identity_string)
32
+ @uri = URI.parse('ssh://' + identity_string) unless @uri.scheme
33
+ @curve_name = curve_name
34
+ @key = key
35
+ end
36
+
37
+ def key
38
+ @key ||= load_public_key
39
+ end
40
+
41
+ def key_name
42
+ "<#{@uri.to_s}|#{@curve_name}>"
43
+ end
44
+
45
+ def export_public_key
46
+ "#{key.ssh_type} #{Base64.strict_encode64(key.to_blob).strip} #{key_name}"
47
+ end
48
+
49
+ def sign(blob)
50
+ response = Device.with_session do |device|
51
+ device.sign_identity(
52
+ challenge_hidden: blob.to_s, ecdsa_curve_name: @curve_name,
53
+ identity: { user: user, host: host, proto: proto }
54
+ )
55
+ end
56
+ return if response.is_a?(Protobuf::Failure)
57
+ signature = response.signature[1..-1]
58
+ if key.ssh_type == SSH_NIST256_KEY_TYPE
59
+ parts = [signature[0..31], signature[32..-1]]
60
+ signature = Buffer.from(:string, parts.map { |p| "\x00" + p }).to_s
61
+ end
62
+ Buffer.from(:string, [key.ssh_type, signature]).to_s
63
+ end
64
+
65
+ private
66
+
67
+ # Compute BIP32 derivation address according to SLIP-0013/0017
68
+ def get_bip32_address(ecdh = false)
69
+ identity_string = [0].pack('<L') + @uri.to_s
70
+ digest = OpenSSL::Digest.digest('SHA256', identity_string)
71
+ hardened = 0x80000000
72
+ addr_0 = ecdh ? 17 : 13
73
+ address_n = [addr_0] + digest.unpack('<L4')
74
+ address_n.map { |n| hardened | n }
75
+ end
76
+
77
+ def raw_public_key
78
+ response = Device.with_session do |device|
79
+ device.get_public_key(address_n: get_bip32_address, ecdsa_curve_name: @curve_name)
80
+ end
81
+ return if response.is_a?(Protobuf::Failure)
82
+ public_key = response.node.public_key
83
+ return public_key[1..-1] unless @curve_name == CURVE_NIST256
84
+ public_key
85
+ end
86
+
87
+ def key_type
88
+ {
89
+ CURVE_NIST256 => SSH_NIST256_KEY_TYPE,
90
+ CURVE_ED25519 => SSH_ED25519_KEY_TYPE,
91
+ ECDH_CURVE25519 => SSH_ED25519_KEY_TYPE
92
+ }[@curve_name]
93
+ end
94
+
95
+ def load_public_key
96
+ parts = [key_type]
97
+ parts << SSH_NIST256_CURVE_NAME if @curve_name == CURVE_NIST256
98
+ parts << raw_public_key
99
+ Buffer.from(:string, parts).read_key
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,77 @@
1
+ require 'trezor/identity'
2
+ require 'trezor/utils'
3
+
4
+ module Trezor
5
+ class KeyManager
6
+ include Enumerable
7
+ include Utils
8
+
9
+ def initialize(identities_file_or_string)
10
+ @path = identities_file_or_string
11
+ end
12
+
13
+ def identities
14
+ @identities ||= load_identities
15
+ end
16
+
17
+ def each
18
+ identities.each { |i| yield i }
19
+ end
20
+
21
+ # For compatibility with Net::SSH::Authentication::KeyManager
22
+ def each_identity
23
+ each { |i| yield i.key }
24
+ end
25
+
26
+ def sign(ssh_identity, blob)
27
+ nonce = blob.read_buffer.to_s
28
+ type = blob.read_byte # SSH2_MSG_USERAUTH_REQUEST == 50 (from ssh2.h, line 108)
29
+ user = blob.read_buffer.to_s
30
+ conn = blob.read_buffer.to_s
31
+ auth = blob.read_buffer.to_s
32
+ have_sig = blob.read_byte # have_sig == 1 (from sshconnect2.c, line 1056)
33
+ key_type = blob.read_buffer.to_s
34
+ public_key = blob.read_buffer.to_s
35
+ identity = identities.find { |i| i.key.fingerprint == ssh_identity.fingerprint }
36
+ return unless identity
37
+ identity.sign(blob)
38
+ end
39
+
40
+ private
41
+
42
+ def load_identities
43
+ if @path.is_a?(Array)
44
+ @path.reduce([]) do |identities, file_or_string|
45
+ identities.concat(self.class.load_from_file_or_string(file_or_string))
46
+ end
47
+ else
48
+ self.class.load_from_file_or_string(@path)
49
+ end
50
+ end
51
+
52
+ private_class_method
53
+
54
+ def self.load_from_file_or_string(file_or_string)
55
+ path = File.expand_path(file_or_string)
56
+ return load_from_file(path) if File.exist?(path)
57
+ [Trezor::Identity.new(file_or_string)]
58
+ end
59
+
60
+ def self.load_from_file(path)
61
+ identity_curve_regex = /\<(.*?)\|(.*?)\>/
62
+
63
+ if path.end_with?('.pub')
64
+ File.foreach(path).map do |line|
65
+ ssh_type, blob, key_name = line.split
66
+ identity_string, curve_name = key_name.scan(identity_curve_regex).first
67
+ key = Buffer.new(Base64.strict_decode64(blob)).read_key
68
+ Trezor::Identity.new(identity_string, curve_name, key)
69
+ end
70
+ else
71
+ File.read(path).scan(identity_curve_regex).map do |identity_string, curve_name|
72
+ Trezor::Identity.new(identity_string, curve_name)
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,38 @@
1
+ require 'active_support/core_ext/string/inflections'
2
+
3
+ require 'trezor/utils/buffer'
4
+ require 'trezor/protobuf/messages.pb'
5
+
6
+ module Trezor
7
+ module Protobuf
8
+ def self.serialize(message)
9
+ msg_type = MessageType.for(message)
10
+ serialized = message.serialize
11
+ header = Utils::Buffer.from(:short, msg_type.to_i, :long, serialized.length)
12
+ payload = Utils::Buffer.from(:bth, [header.to_s, serialized])
13
+ payload.to_s
14
+ end
15
+
16
+ def self.decode(blob)
17
+ buffer = Utils::Buffer.from(:htb, blob)
18
+ MessageType[buffer.read_short].decode(buffer.read_string)
19
+ end
20
+
21
+ def self.[](class_name)
22
+ const_get(class_name.to_s.camelize)
23
+ rescue NameError
24
+ # log debug message
25
+ end
26
+
27
+ class MessageType
28
+ def self.[](tag)
29
+ message_type_name = fetch(tag).name.to_s.split('_').last
30
+ Protobuf[message_type_name]
31
+ end
32
+
33
+ def self.for(message)
34
+ fetch("MessageType_#{message.class.name.demodulize}")
35
+ end
36
+ end
37
+ end
38
+ end