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.
- checksums.yaml +7 -0
- data/.gitignore +42 -0
- data/.rspec +3 -0
- data/.travis.yml +5 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +6 -0
- data/LICENSE +165 -0
- data/README.md +68 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/exe/trezor-agent +41 -0
- data/lib/net/ssh/authentication/methods/trezor.rb +25 -0
- data/lib/trezor.rb +2 -0
- data/lib/trezor/agent.rb +88 -0
- data/lib/trezor/device.rb +141 -0
- data/lib/trezor/identity.rb +102 -0
- data/lib/trezor/key_manager.rb +77 -0
- data/lib/trezor/protobuf.rb +38 -0
- data/lib/trezor/protobuf/messages.pb.rb +866 -0
- data/lib/trezor/protobuf/types.pb.rb +392 -0
- data/lib/trezor/utils.rb +6 -0
- data/lib/trezor/utils/buffer.rb +39 -0
- data/lib/trezor/version.rb +3 -0
- data/trezor.gemspec +36 -0
- metadata +254 -0
@@ -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
data/lib/trezor/agent.rb
ADDED
@@ -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
|