crussh 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/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +371 -0
- data/ext/poly1305/Cargo.toml +13 -0
- data/ext/poly1305/extconf.rb +6 -0
- data/ext/poly1305/src/lib.rs +75 -0
- data/lib/crussh/auth.rb +46 -0
- data/lib/crussh/channel/key_parser.rb +125 -0
- data/lib/crussh/channel.rb +381 -0
- data/lib/crussh/cipher/algorithm.rb +31 -0
- data/lib/crussh/cipher/chacha20poly1305.rb +98 -0
- data/lib/crussh/cipher.rb +25 -0
- data/lib/crussh/compression.rb +42 -0
- data/lib/crussh/gatekeeper.rb +50 -0
- data/lib/crussh/handler/line_buffer.rb +131 -0
- data/lib/crussh/handler.rb +128 -0
- data/lib/crussh/heartbeat.rb +68 -0
- data/lib/crussh/kex/algorithm.rb +86 -0
- data/lib/crussh/kex/curve25519.rb +30 -0
- data/lib/crussh/kex/exchange.rb +234 -0
- data/lib/crussh/kex.rb +42 -0
- data/lib/crussh/keys/key_pair.rb +61 -0
- data/lib/crussh/keys/public_key.rb +35 -0
- data/lib/crussh/keys.rb +70 -0
- data/lib/crussh/limits.rb +45 -0
- data/lib/crussh/logger.rb +95 -0
- data/lib/crussh/mac/algorithm.rb +23 -0
- data/lib/crussh/mac/crypto.rb +60 -0
- data/lib/crussh/mac/none.rb +9 -0
- data/lib/crussh/mac.rb +28 -0
- data/lib/crussh/negotiator.rb +41 -0
- data/lib/crussh/preferred.rb +16 -0
- data/lib/crussh/protocol/channel_close.rb +11 -0
- data/lib/crussh/protocol/channel_data.rb +12 -0
- data/lib/crussh/protocol/channel_eof.rb +11 -0
- data/lib/crussh/protocol/channel_extended_data.rb +13 -0
- data/lib/crussh/protocol/channel_failure.rb +11 -0
- data/lib/crussh/protocol/channel_open.rb +69 -0
- data/lib/crussh/protocol/channel_open_confirmation.rb +15 -0
- data/lib/crussh/protocol/channel_open_failure.rb +14 -0
- data/lib/crussh/protocol/channel_request.rb +146 -0
- data/lib/crussh/protocol/channel_success.rb +11 -0
- data/lib/crussh/protocol/channel_window_adjust.rb +12 -0
- data/lib/crussh/protocol/debug.rb +15 -0
- data/lib/crussh/protocol/disconnect.rb +39 -0
- data/lib/crussh/protocol/ext_info.rb +48 -0
- data/lib/crussh/protocol/global_request.rb +46 -0
- data/lib/crussh/protocol/ignore.rb +11 -0
- data/lib/crussh/protocol/kex_ecdh_init.rb +11 -0
- data/lib/crussh/protocol/kex_ecdh_reply.rb +13 -0
- data/lib/crussh/protocol/kex_init.rb +38 -0
- data/lib/crussh/protocol/new_keys.rb +9 -0
- data/lib/crussh/protocol/ping.rb +11 -0
- data/lib/crussh/protocol/pong.rb +11 -0
- data/lib/crussh/protocol/request_failure.rb +9 -0
- data/lib/crussh/protocol/request_success.rb +11 -0
- data/lib/crussh/protocol/service_accept.rb +11 -0
- data/lib/crussh/protocol/service_request.rb +11 -0
- data/lib/crussh/protocol/unimplemented.rb +11 -0
- data/lib/crussh/protocol/userauth_banner.rb +12 -0
- data/lib/crussh/protocol/userauth_failure.rb +12 -0
- data/lib/crussh/protocol/userauth_pk_ok.rb +12 -0
- data/lib/crussh/protocol/userauth_request.rb +52 -0
- data/lib/crussh/protocol/userauth_success.rb +9 -0
- data/lib/crussh/protocol.rb +135 -0
- data/lib/crussh/server/auth_handler.rb +18 -0
- data/lib/crussh/server/config.rb +157 -0
- data/lib/crussh/server/layers/connection.rb +363 -0
- data/lib/crussh/server/layers/transport.rb +49 -0
- data/lib/crussh/server/layers/userauth.rb +232 -0
- data/lib/crussh/server/request_rule.rb +76 -0
- data/lib/crussh/server/session.rb +192 -0
- data/lib/crussh/server.rb +214 -0
- data/lib/crussh/ssh_id.rb +44 -0
- data/lib/crussh/transport/packet_stream.rb +245 -0
- data/lib/crussh/transport/reader.rb +98 -0
- data/lib/crussh/transport/version_exchange.rb +26 -0
- data/lib/crussh/transport/writer.rb +72 -0
- data/lib/crussh/version.rb +5 -0
- data/lib/crussh.rb +61 -0
- data/sig/crussh.rbs +4 -0
- metadata +249 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Crussh
|
|
4
|
+
class Handler
|
|
5
|
+
class LineBuffer
|
|
6
|
+
def initialize(channel, echo: true)
|
|
7
|
+
@channel = channel
|
|
8
|
+
@buffer = +""
|
|
9
|
+
@cursor = 0
|
|
10
|
+
@echo = echo
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
attr_reader :buffer
|
|
14
|
+
|
|
15
|
+
def handle(key)
|
|
16
|
+
case key
|
|
17
|
+
when String
|
|
18
|
+
insert(key)
|
|
19
|
+
when :backspace
|
|
20
|
+
backspace
|
|
21
|
+
when :delete
|
|
22
|
+
delete
|
|
23
|
+
when :arrow_left
|
|
24
|
+
move_left
|
|
25
|
+
when :arrow_right
|
|
26
|
+
move_right
|
|
27
|
+
when :home
|
|
28
|
+
move_to_start
|
|
29
|
+
when :end
|
|
30
|
+
move_to_end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def flush
|
|
35
|
+
line = @buffer
|
|
36
|
+
@buffer = +""
|
|
37
|
+
@cursor = 0
|
|
38
|
+
line
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def clear
|
|
42
|
+
if @echo && @buffer.length.positive?
|
|
43
|
+
move_to_start
|
|
44
|
+
@channel.print("\e[K")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
@buffer = +""
|
|
48
|
+
@cursor = 0
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def cursor_at_start?
|
|
54
|
+
@cursor.zero?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def cursor_at_end?
|
|
58
|
+
@cursor == @buffer.length
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def insert(char)
|
|
62
|
+
if cursor_at_end?
|
|
63
|
+
@buffer << char
|
|
64
|
+
@cursor += 1
|
|
65
|
+
@channel.print(char) if @echo
|
|
66
|
+
return
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
@buffer.insert(@cursor, char)
|
|
70
|
+
@cursor += 1
|
|
71
|
+
redraw_from_cursor if @echo
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def backspace
|
|
75
|
+
return if cursor_at_start?
|
|
76
|
+
|
|
77
|
+
@cursor -= 1
|
|
78
|
+
@buffer.slice!(@cursor)
|
|
79
|
+
|
|
80
|
+
if @echo
|
|
81
|
+
@channel.print("\b")
|
|
82
|
+
redraw_from_cursor
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def delete
|
|
87
|
+
return if cursor_at_end?
|
|
88
|
+
|
|
89
|
+
@buffer.slice!(@cursor)
|
|
90
|
+
redraw_from_cursor if @echo
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def move_left
|
|
94
|
+
return if cursor_at_start?
|
|
95
|
+
|
|
96
|
+
@cursor -= 1
|
|
97
|
+
@channel.print("\e[D") if @echo
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def move_right
|
|
101
|
+
return if cursor_at_end?
|
|
102
|
+
|
|
103
|
+
@cursor += 1
|
|
104
|
+
@channel.print("\e[C") if @echo
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def move_to_start
|
|
108
|
+
return if cursor_at_start?
|
|
109
|
+
|
|
110
|
+
@channel.print("\e[#{@cursor}D") if @echo && @cursor > 0
|
|
111
|
+
@cursor = 0
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def move_to_end
|
|
115
|
+
return if cursor_at_end?
|
|
116
|
+
|
|
117
|
+
distance = @buffer.length - @cursor
|
|
118
|
+
@channel.print("\e[#{distance}C") if @echo && distance > 0
|
|
119
|
+
@cursor = @buffer.length
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def redraw_from_cursor
|
|
123
|
+
rest = @buffer[@cursor..]
|
|
124
|
+
|
|
125
|
+
@channel.print(rest)
|
|
126
|
+
@channel.print("\e[K")
|
|
127
|
+
@channel.print("\e[#{rest.length}D") if rest.empty?
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/callbacks"
|
|
4
|
+
require "active_support/rescuable"
|
|
5
|
+
|
|
6
|
+
module Crussh
|
|
7
|
+
class Handler
|
|
8
|
+
include ActiveSupport::Callbacks
|
|
9
|
+
include ActiveSupport::Rescuable
|
|
10
|
+
|
|
11
|
+
define_callbacks :handle
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def before(*methods, **options, &block)
|
|
15
|
+
set_callback(:handle, :before, *methods, **options, &block)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def after(*methods, **options, &block)
|
|
19
|
+
set_callback(:handle, :after, *methods, **options, &block)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def around(*methods, **options, &block)
|
|
23
|
+
set_callback(:handle, :around, *methods, **options, &block)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(channel, session, ...)
|
|
28
|
+
@channel = channel
|
|
29
|
+
@session = session
|
|
30
|
+
|
|
31
|
+
setup(...)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def setup(...); end
|
|
35
|
+
|
|
36
|
+
def call
|
|
37
|
+
run_callbacks(:handle) { handle }
|
|
38
|
+
rescue => e
|
|
39
|
+
rescue_with_handler(e) || raise
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def resize(...); end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
attr_reader :session
|
|
47
|
+
|
|
48
|
+
def user = session.user
|
|
49
|
+
def config = session.config
|
|
50
|
+
def pty = channel.pty
|
|
51
|
+
def pty? = channel.pty?
|
|
52
|
+
def env = channel.env
|
|
53
|
+
|
|
54
|
+
def logger
|
|
55
|
+
# TODO: Some sort of logging context
|
|
56
|
+
@logger ||= Logger
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def puts(...) = channel.puts(...)
|
|
60
|
+
def print(...) = channel.print(...)
|
|
61
|
+
def gets(...) = channel.gets(...)
|
|
62
|
+
def read(...) = channel.read(...)
|
|
63
|
+
def write(...) = channel.write(...)
|
|
64
|
+
|
|
65
|
+
def close = channel.close
|
|
66
|
+
def send_eof = channel.send_eof
|
|
67
|
+
def exit_status(...) = channel.exit_status(...)
|
|
68
|
+
def exit_signal(...) = channel.exit_signal(...)
|
|
69
|
+
|
|
70
|
+
def each_event(...) = channel.each(...)
|
|
71
|
+
|
|
72
|
+
def each_key(&block)
|
|
73
|
+
return enum_for(:each_key) unless block_given?
|
|
74
|
+
|
|
75
|
+
parser = Channel::KeyParser.new
|
|
76
|
+
|
|
77
|
+
each_event do |event|
|
|
78
|
+
case event
|
|
79
|
+
when Channel::Data
|
|
80
|
+
event.each_key(parser: parser, &block)
|
|
81
|
+
when Channel::WindowChange
|
|
82
|
+
resize(event.width, event.height) if respond_to?(:resize, true)
|
|
83
|
+
when Channel::EOF
|
|
84
|
+
yield :eof
|
|
85
|
+
when Channel::Closed
|
|
86
|
+
return
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def each_line(prompt: "", echo: true)
|
|
92
|
+
return enum_for(:each_line, prompt:, echo:) unless block_given?
|
|
93
|
+
|
|
94
|
+
buffer = LineBuffer.new(channel, echo:)
|
|
95
|
+
print_prompt(prompt)
|
|
96
|
+
|
|
97
|
+
each_key do |key|
|
|
98
|
+
case key
|
|
99
|
+
when :enter
|
|
100
|
+
puts if echo
|
|
101
|
+
line = buffer.flush
|
|
102
|
+
yield line unless line.empty?
|
|
103
|
+
print_prompt(prompt)
|
|
104
|
+
when :interrupt
|
|
105
|
+
puts if echo
|
|
106
|
+
buffer.clear
|
|
107
|
+
print_prompt(prompt)
|
|
108
|
+
when :eof
|
|
109
|
+
puts if echo
|
|
110
|
+
return
|
|
111
|
+
else
|
|
112
|
+
buffer.handle(key)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def print_prompt(prompt)
|
|
118
|
+
case prompt
|
|
119
|
+
when String then print(prompt)
|
|
120
|
+
when Proc then print(prompt.call)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
protected
|
|
125
|
+
|
|
126
|
+
attr_reader(:channel)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Crussh
|
|
4
|
+
class Heartbeat
|
|
5
|
+
KEEPALIVE_REQUEST = "keepalive@openssh.com"
|
|
6
|
+
|
|
7
|
+
def initialize(session, interval:, max:)
|
|
8
|
+
@session = session
|
|
9
|
+
@interval = interval
|
|
10
|
+
@max = max
|
|
11
|
+
@missed = 0
|
|
12
|
+
@last_activity = Time.now
|
|
13
|
+
@task = nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
attr_reader :missed
|
|
17
|
+
|
|
18
|
+
def start(task: Async::Task.current)
|
|
19
|
+
return if @interval.nil?
|
|
20
|
+
return if @task&.running?
|
|
21
|
+
|
|
22
|
+
@task = task.async do
|
|
23
|
+
loop do
|
|
24
|
+
sleep(@interval)
|
|
25
|
+
|
|
26
|
+
if time_since_last_activity >= @interval
|
|
27
|
+
@missed += 1
|
|
28
|
+
|
|
29
|
+
if session_unresponsive?
|
|
30
|
+
@session.disconnect(:connection_lost, "Keepalive timeout")
|
|
31
|
+
break
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
send_keepalive
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def stop
|
|
41
|
+
@task&.stop
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def record_activity!
|
|
45
|
+
@last_activity = Time.now
|
|
46
|
+
@missed = 0
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def send_keepalive
|
|
52
|
+
message = Protocol::GlobalRequest.new(
|
|
53
|
+
request_type: KEEPALIVE_REQUEST,
|
|
54
|
+
)
|
|
55
|
+
@session.write_packet(message)
|
|
56
|
+
rescue IOError, Errno::ECONNRESET, ConnectionClosed
|
|
57
|
+
stop
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def session_unresponsive?
|
|
61
|
+
@missed > @max
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def time_since_last_activity
|
|
65
|
+
Time.now - @last_activity
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Crussh
|
|
4
|
+
module Kex
|
|
5
|
+
class Algorithm
|
|
6
|
+
attr_reader :shared_secret
|
|
7
|
+
|
|
8
|
+
def skip_exchange?
|
|
9
|
+
false
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def digest(data)
|
|
13
|
+
raise NotImplementedError
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def client_dh_init
|
|
17
|
+
generate_keypair
|
|
18
|
+
|
|
19
|
+
@public_key
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def server_dh_reply(client_public)
|
|
23
|
+
generate_keypair
|
|
24
|
+
compute_shared_secret(client_public)
|
|
25
|
+
|
|
26
|
+
@public_key
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def client_dh_finish(server_public)
|
|
30
|
+
compute_shared_secret(server_public)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def compute_exchange_hash(exchange)
|
|
34
|
+
writer = Transport::Writer.new
|
|
35
|
+
|
|
36
|
+
writer
|
|
37
|
+
.string(exchange.client_id)
|
|
38
|
+
.string(exchange.server_id)
|
|
39
|
+
.string(exchange.client_kexinit)
|
|
40
|
+
.string(exchange.server_kexinit)
|
|
41
|
+
.string(exchange.server_host_key)
|
|
42
|
+
.string(exchange.client_public)
|
|
43
|
+
.string(exchange.server_public)
|
|
44
|
+
.mpint(shared_secret)
|
|
45
|
+
|
|
46
|
+
digest(writer.to_s)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def derive_keys(session_id:, exchange_hash:, cipher:, mac_c2s:, mac_s2c:, we_are_server:)
|
|
50
|
+
if we_are_server
|
|
51
|
+
{
|
|
52
|
+
iv_send: derive_key("B", cipher.block_size, session_id, exchange_hash),
|
|
53
|
+
iv_recv: derive_key("A", cipher.block_size, session_id, exchange_hash),
|
|
54
|
+
key_send: derive_key("D", cipher.key_length, session_id, exchange_hash),
|
|
55
|
+
key_recv: derive_key("C", cipher.key_length, session_id, exchange_hash),
|
|
56
|
+
mac_send: derive_key("F", mac_s2c&.key_length || 0, session_id, exchange_hash),
|
|
57
|
+
mac_recv: derive_key("E", mac_c2s&.key_length || 0, session_id, exchange_hash),
|
|
58
|
+
}
|
|
59
|
+
else
|
|
60
|
+
{
|
|
61
|
+
iv_send: derive_key("A", cipher.block_size, session_id, exchange_hash),
|
|
62
|
+
iv_recv: derive_key("B", cipher.block_size, session_id, exchange_hash),
|
|
63
|
+
key_send: derive_key("C", cipher.key_length, session_id, exchange_hash),
|
|
64
|
+
key_recv: derive_key("D", cipher.key_length, session_id, exchange_hash),
|
|
65
|
+
mac_send: derive_key("E", mac_c2s&.key_length || 0, session_id, exchange_hash),
|
|
66
|
+
mac_recv: derive_key("F", mac_s2c&.key_length || 0, session_id, exchange_hash),
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def derive_key(letter, needed_length, session_id, exchange_hash)
|
|
74
|
+
return "".b if needed_length == 0
|
|
75
|
+
|
|
76
|
+
writer = Transport::Writer.new
|
|
77
|
+
k_encoded = writer.mpint(shared_secret).to_s
|
|
78
|
+
|
|
79
|
+
key = digest(k_encoded + exchange_hash + letter + session_id)
|
|
80
|
+
key += digest(k_encoded + exchange_hash + key) while key.bytesize < needed_length
|
|
81
|
+
|
|
82
|
+
key[0, needed_length]
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "x25519"
|
|
4
|
+
|
|
5
|
+
module Crussh
|
|
6
|
+
module Kex
|
|
7
|
+
class Curve25519 < Algorithm
|
|
8
|
+
PUBLIC_KEY_SIZE = 32
|
|
9
|
+
PRIVATE_KEY_SIZE = 32
|
|
10
|
+
|
|
11
|
+
def digest(data)
|
|
12
|
+
Digest::SHA256.digest(data)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def generate_keypair
|
|
16
|
+
@private_key = X25519::Scalar.generate
|
|
17
|
+
@public_key = @private_key.public_key.to_bytes
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def compute_shared_secret(their_public_key)
|
|
21
|
+
raise KexError, "Invalid public key size" if their_public_key.bytesize != PUBLIC_KEY_SIZE
|
|
22
|
+
|
|
23
|
+
their_key = X25519::MontgomeryU.new(their_public_key)
|
|
24
|
+
shared = @private_key.diffie_hellman(their_key)
|
|
25
|
+
|
|
26
|
+
@shared_secret = shared.to_bytes.unpack1("H*").to_i(16)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Crussh
|
|
4
|
+
module Kex
|
|
5
|
+
# TODO: Technically this is just for the server,
|
|
6
|
+
# so maybe we move it to be in the server module scope
|
|
7
|
+
class Exchange
|
|
8
|
+
PING_EXTENSION = "ping@openssh.com"
|
|
9
|
+
SERVER_SIGNATURE_ALGORITHMS = "server-sig-algs"
|
|
10
|
+
|
|
11
|
+
def initialize(session)
|
|
12
|
+
@session = session
|
|
13
|
+
@session_id = session.id
|
|
14
|
+
@received_messages = Set.new
|
|
15
|
+
@extra_messages_before_kexinit = 0
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
attr_reader :algorithms, :session_id
|
|
19
|
+
|
|
20
|
+
def config = @session.config
|
|
21
|
+
def strict_kex? = @session.strict_kex?
|
|
22
|
+
def initial? = @session_id.nil?
|
|
23
|
+
|
|
24
|
+
def initial(client_version:)
|
|
25
|
+
@client_version = client_version
|
|
26
|
+
@client_kexinit_payload = read_initial_kexinit
|
|
27
|
+
|
|
28
|
+
client_kexinit = Protocol::KexInit.parse(@client_kexinit_payload)
|
|
29
|
+
|
|
30
|
+
validate_strictness!(client_kexinit)
|
|
31
|
+
|
|
32
|
+
perform_full_key_exchange(client_kexinit)
|
|
33
|
+
|
|
34
|
+
if client_supports_ext_info?(client_kexinit)
|
|
35
|
+
send_ext_info
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def start_rekey
|
|
40
|
+
server_kexinit = Protocol::KexInit.from_preferred(config.preferred)
|
|
41
|
+
@server_kexinit_payload = server_kexinit.serialize
|
|
42
|
+
@session.write_raw_packet(server_kexinit)
|
|
43
|
+
|
|
44
|
+
@client_kexinit_payload = @session.read_raw_packet
|
|
45
|
+
client_kexinit = Protocol::KexInit.parse(@client_kexinit_payload)
|
|
46
|
+
|
|
47
|
+
negotiate_and_exchange(client_kexinit, server_kexinit)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def rekey(client_kexinit_payload:)
|
|
51
|
+
@client_kexinit_payload = client_kexinit_payload
|
|
52
|
+
client_kexinit = Protocol::KexInit.parse(@client_kexinit_payload)
|
|
53
|
+
|
|
54
|
+
perform_full_key_exchange(client_kexinit)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def perform_full_key_exchange(client_kexinit)
|
|
60
|
+
server_kexinit = Protocol::KexInit.from_preferred(config.preferred)
|
|
61
|
+
@server_kexinit_payload = server_kexinit.serialize
|
|
62
|
+
@session.write_raw_packet(server_kexinit)
|
|
63
|
+
|
|
64
|
+
negotiate_and_exchange(client_kexinit, server_kexinit)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def validate_strictness!(client_kexinit)
|
|
68
|
+
@session.strict_kex = client_supports_strict?(client_kexinit)
|
|
69
|
+
|
|
70
|
+
if strict_kex? && @extra_messages_before_kexinit.positive?
|
|
71
|
+
raise ProtocolError, "Strict KEX: KEXINIT was not the first message"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def negotiate_and_exchange(client_kexinit, server_kexinit)
|
|
76
|
+
@algorithms = Negotiator.new(client_kexinit, server_kexinit).negotiate
|
|
77
|
+
|
|
78
|
+
perform_dh_exchange
|
|
79
|
+
derive_and_enable_keys
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def perform_dh_exchange
|
|
83
|
+
packet = read_kex_packet(Protocol::KEX_ECDH_INIT)
|
|
84
|
+
kex_dh_init = Protocol::KexEcdhInit.parse(packet)
|
|
85
|
+
client_public = kex_dh_init.public_key
|
|
86
|
+
|
|
87
|
+
@kex_algorithm = Kex.from_name(@algorithms.kex)
|
|
88
|
+
server_public = @kex_algorithm.server_dh_reply(client_public)
|
|
89
|
+
|
|
90
|
+
parameters = Parameters.new(
|
|
91
|
+
client_id: @client_version.to_s,
|
|
92
|
+
server_id: config.server_id.to_s,
|
|
93
|
+
client_kexinit: @client_kexinit_payload,
|
|
94
|
+
server_kexinit: @server_kexinit_payload,
|
|
95
|
+
server_host_key: host_key.public_key_blob,
|
|
96
|
+
client_public: client_public,
|
|
97
|
+
server_public: server_public,
|
|
98
|
+
shared_secret: @kex_algorithm.shared_secret,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
@exchange_hash = @kex_algorithm.compute_exchange_hash(parameters)
|
|
102
|
+
signature = host_key.sign(@exchange_hash)
|
|
103
|
+
|
|
104
|
+
kex_ecdh_reply = Protocol::KexEcdhReply.new(
|
|
105
|
+
public_host_key: host_key.public_key_blob,
|
|
106
|
+
public_key: server_public,
|
|
107
|
+
signature: signature,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
@session.write_raw_packet(kex_ecdh_reply)
|
|
111
|
+
@session.write_raw_packet(Protocol::NewKeys.new)
|
|
112
|
+
|
|
113
|
+
packet = read_kex_packet(Protocol::NEWKEYS)
|
|
114
|
+
Protocol::NewKeys.parse(packet)
|
|
115
|
+
|
|
116
|
+
@session_id ||= @exchange_hash
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def derive_and_enable_keys
|
|
120
|
+
cipher = Cipher.from_name(@algorithms.cipher_server_to_client)
|
|
121
|
+
|
|
122
|
+
keys = @kex_algorithm.derive_keys(
|
|
123
|
+
session_id: @session_id,
|
|
124
|
+
exchange_hash: @exchange_hash,
|
|
125
|
+
cipher: cipher,
|
|
126
|
+
mac_c2s: nil,
|
|
127
|
+
mac_s2c: nil,
|
|
128
|
+
we_are_server: true,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
opening_key = cipher.make_opening_key(key: keys[:key_recv])
|
|
132
|
+
sealing_key = cipher.make_sealing_key(key: keys[:key_send])
|
|
133
|
+
|
|
134
|
+
@session.enable_encryption(opening_key, sealing_key)
|
|
135
|
+
@session.reset_sequence if strict_kex?
|
|
136
|
+
@session.algorithms = @algorithms
|
|
137
|
+
|
|
138
|
+
Logger.info(self, "Keys exchanged", cipher: @algorithms.cipher_server_to_client)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def send_ext_info
|
|
142
|
+
signature_algorithms = config.preferred.host_key.join(",")
|
|
143
|
+
|
|
144
|
+
extensions = {
|
|
145
|
+
SERVER_SIGNATURE_ALGORITHMS => signature_algorithms,
|
|
146
|
+
PING_EXTENSION => "0",
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
ext_info = Protocol::ExtInfo.new(extensions: extensions)
|
|
150
|
+
@session.write_packet(ext_info)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def read_kex_packet(expected_type)
|
|
154
|
+
validate_sequence_hasnt_wrapped! if strict_kex? && initial?
|
|
155
|
+
|
|
156
|
+
packet = @session.read_raw_packet
|
|
157
|
+
message_type = packet.getbyte(0)
|
|
158
|
+
|
|
159
|
+
if strict_kex? && initial?
|
|
160
|
+
unless kex_message?(message_type)
|
|
161
|
+
raise ProtocolError, "Strict KEX: unexpected message type #{message_type} during initial KEX"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
if @received_messages.include?(message_type)
|
|
165
|
+
raise ProtocolError, "Strict KEX: duplicate message type #{message_type}"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
@received_messages.add(message_type)
|
|
169
|
+
else
|
|
170
|
+
while message_type == Protocol::IGNORE || message_type == Protocol::DEBUG
|
|
171
|
+
packet = @session.read_raw_packet
|
|
172
|
+
message_type = packet.getbyte(0)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
unless message_type == expected_type
|
|
177
|
+
raise ProtocolError, "Unexpected message type #{message_type}, expected #{expected_type}"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
packet
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def read_initial_kexinit
|
|
184
|
+
packet = @session.read_raw_packet
|
|
185
|
+
message_type = packet.getbyte(0)
|
|
186
|
+
|
|
187
|
+
while message_type == Protocol::IGNORE || message_type == Protocol::DEBUG
|
|
188
|
+
@extra_messages_before_kexinit += 1
|
|
189
|
+
packet = @session.read_raw_packet
|
|
190
|
+
message_type = packet.getbyte(0)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
unless message_type == Protocol::KEXINIT
|
|
194
|
+
raise ProtocolError, "Expected KEXINIT, got message type #{message_type}"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
@received_messages.add(Protocol::KEXINIT)
|
|
198
|
+
|
|
199
|
+
packet
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def validate_sequence_hasnt_wrapped!
|
|
203
|
+
return unless @session.sequence_wrapped?
|
|
204
|
+
|
|
205
|
+
raise ProtocolError, "Strict KEX: sequence number wrapped during initial KEX"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def client_supports_strict?(client_kexinit)
|
|
209
|
+
client_kexinit.kex_algorithms.include?(STRICT_CLIENT)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def client_supports_ext_info?(client_kexinit)
|
|
213
|
+
client_kexinit.kex_algorithms.include?(EXT_INFO_CLIENT)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def kex_message?(type)
|
|
217
|
+
case type
|
|
218
|
+
when Protocol::KEXINIT, Protocol::NEWKEYS,
|
|
219
|
+
Protocol::KEX_ECDH_INIT, Protocol::KEX_ECDH_REPLY
|
|
220
|
+
# TODO: Add other KEX message types when implementing those algorithms:
|
|
221
|
+
# - SSH_MSG_KEXDH_INIT (30), SSH_MSG_KEXDH_REPLY (31) for DH
|
|
222
|
+
# - SSH_MSG_KEX_DH_GEX_* for DH group exchange
|
|
223
|
+
true
|
|
224
|
+
else
|
|
225
|
+
false
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def host_key
|
|
230
|
+
@host_key ||= config.host_keys.find { |key| key.algorithm == @algorithms.host_key }
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|