ruby_smb 3.3.23 → 3.3.25
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 +4 -4
- data/lib/ruby_smb/client.rb +14 -3
- data/lib/ruby_smb/error.rb +11 -1
- data/lib/ruby_smb/gss/provider/kerberos.rb +155 -0
- data/lib/ruby_smb/gss/provider/multi.rb +129 -0
- data/lib/ruby_smb/gss/provider/ntlm.rb +5 -20
- data/lib/ruby_smb/gss/provider.rb +22 -0
- data/lib/ruby_smb/gss/spnego_neg_token_init.rb +81 -0
- data/lib/ruby_smb/gss/spnego_neg_token_targ.rb +27 -0
- data/lib/ruby_smb/gss.rb +23 -0
- data/lib/ruby_smb/nbss/name_service_header_flags.rb +18 -0
- data/lib/ruby_smb/nbss/negative_session_response.rb +5 -5
- data/lib/ruby_smb/nbss/node_status.rb +137 -0
- data/lib/ruby_smb/nbss/node_status_request.rb +38 -0
- data/lib/ruby_smb/nbss/node_status_response.rb +85 -0
- data/lib/ruby_smb/nbss.rb +11 -0
- data/lib/ruby_smb/version.rb +1 -1
- data/ruby_smb.gemspec +3 -0
- data/spec/lib/ruby_smb/client_spec.rb +23 -0
- data/spec/lib/ruby_smb/gss/provider/kerberos_spec.rb +206 -0
- data/spec/lib/ruby_smb/gss/provider/multi_spec.rb +174 -0
- data/spec/lib/ruby_smb/gss/spnego_spec.rb +80 -0
- data/spec/lib/ruby_smb/nbss/node_status_request_spec.rb +52 -0
- data/spec/lib/ruby_smb/nbss/node_status_response_spec.rb +75 -0
- data/spec/lib/ruby_smb/nbss/node_status_spec.rb +146 -0
- metadata +36 -2
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
require 'socket'
|
|
2
|
+
require 'ipaddr'
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module RubySMB
|
|
6
|
+
module Nbss
|
|
7
|
+
# Pure-Ruby implementation of `nmblookup -A <ip>`: sends an NBNS Node
|
|
8
|
+
# Status Request (RFC 1002 4.2.17) over UDP/137 and returns the
|
|
9
|
+
# server's name table.
|
|
10
|
+
#
|
|
11
|
+
# No external binaries are invoked. Compare to Samba's `nmblookup`,
|
|
12
|
+
# which shells out and requires the `samba-common-bin` package to be
|
|
13
|
+
# installed.
|
|
14
|
+
module NodeStatus
|
|
15
|
+
NBNS_PORT = 137
|
|
16
|
+
|
|
17
|
+
# Default per-attempt receive timeout, in seconds.
|
|
18
|
+
DEFAULT_TIMEOUT = 2.0
|
|
19
|
+
|
|
20
|
+
# Default number of attempts before giving up.
|
|
21
|
+
DEFAULT_RETRIES = 3
|
|
22
|
+
|
|
23
|
+
# One entry in the returned name table.
|
|
24
|
+
#
|
|
25
|
+
# @!attribute [rw] name [String] the NetBIOS name (trimmed)
|
|
26
|
+
# @!attribute [rw] suffix [Integer] 1-byte NetBIOS suffix
|
|
27
|
+
# @!attribute [rw] group [Boolean] true for a group name, false for unique
|
|
28
|
+
# @!attribute [rw] active [Boolean] true if the name is registered
|
|
29
|
+
Entry = Struct.new(:name, :suffix, :group, :active) do
|
|
30
|
+
def unique?
|
|
31
|
+
!group
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Human-readable form like `WIN95 <20> UNIQUE ACTIVE`.
|
|
35
|
+
def to_s
|
|
36
|
+
flags = [group ? 'GROUP' : 'UNIQUE', active ? 'ACTIVE' : 'INACTIVE'].join(' ')
|
|
37
|
+
format('%-16s <%02X> %s', name, suffix, flags)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Query a host for its NetBIOS name table.
|
|
42
|
+
#
|
|
43
|
+
# NBNS is IPv4-only and this is a unicast query, so `host` must be a
|
|
44
|
+
# numeric IPv4 address (e.g. `10.0.0.1`). A hostname is rejected because
|
|
45
|
+
# replies are matched against the numeric peer address returned by
|
|
46
|
+
# `recvfrom`, which a hostname would never equal.
|
|
47
|
+
#
|
|
48
|
+
# @param host [String] target IPv4 address (unicast — no broadcast)
|
|
49
|
+
# @param port [Integer] destination UDP port (default 137)
|
|
50
|
+
# @param timeout [Numeric] per-attempt receive timeout in seconds
|
|
51
|
+
# @param retries [Integer] total number of attempts
|
|
52
|
+
# @param udp_socket [UDPSocket, Rex::Socket::Udp] caller-owned UDP socket.
|
|
53
|
+
# The caller is responsible for binding and closing it. It must
|
|
54
|
+
# implement `#send(mesg, flags, host, port)` and `#recvfrom(maxlen)`,
|
|
55
|
+
# and leave `do_not_reverse_lookup` at its default so `recvfrom` does
|
|
56
|
+
# not perform a reverse DNS lookup per datagram.
|
|
57
|
+
# @return [Array<Entry>, nil] the name table, or nil on timeout/parse failure
|
|
58
|
+
# @raise [ArgumentError] if `host` is not a numeric IPv4 address
|
|
59
|
+
def self.query(host, port: NBNS_PORT, timeout: DEFAULT_TIMEOUT,
|
|
60
|
+
retries: DEFAULT_RETRIES, udp_socket:)
|
|
61
|
+
expected_address = begin
|
|
62
|
+
IPAddr.new(host).native
|
|
63
|
+
rescue IPAddr::InvalidAddressError
|
|
64
|
+
raise ArgumentError, "host must be an IPv4 address, got #{host.inspect}"
|
|
65
|
+
end
|
|
66
|
+
raise ArgumentError, "NBNS is IPv4-only, got #{host.inspect}" unless expected_address.ipv4?
|
|
67
|
+
|
|
68
|
+
request = NodeStatusRequest.new(transaction_id: SecureRandom.random_number(0x10000))
|
|
69
|
+
bytes = request.to_binary_s
|
|
70
|
+
|
|
71
|
+
retries.times do
|
|
72
|
+
udp_socket.send(bytes, 0, host, port)
|
|
73
|
+
next unless IO.select([udp_socket], nil, nil, timeout)
|
|
74
|
+
|
|
75
|
+
data, addr = udp_socket.recvfrom(4096)
|
|
76
|
+
next if data.nil? || data.empty?
|
|
77
|
+
next unless source_matches?(addr, expected_address)
|
|
78
|
+
|
|
79
|
+
response = NodeStatusResponse.read(data)
|
|
80
|
+
# Reject anything that isn't the response to our own query.
|
|
81
|
+
next unless response.transaction_id.to_i == request.transaction_id.to_i
|
|
82
|
+
next unless response.response.to_i == 1
|
|
83
|
+
next unless response.rr_type.to_i == NodeStatusRequest::QUESTION_TYPE_NBSTAT
|
|
84
|
+
|
|
85
|
+
return entries_from(response)
|
|
86
|
+
rescue IOError, EOFError, SystemCallError
|
|
87
|
+
next
|
|
88
|
+
end
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Return the unique file-server name (suffix 0x20) from a host, or nil
|
|
93
|
+
# if the name table doesn't contain one. Convenience helper for the
|
|
94
|
+
# common case of "give me this host's file-server name."
|
|
95
|
+
#
|
|
96
|
+
# @param host [String] target IPv4 address
|
|
97
|
+
# @param kwargs [Hash] forwarded to {.query}
|
|
98
|
+
# @return [String, nil]
|
|
99
|
+
def self.file_server_name(host, **kwargs)
|
|
100
|
+
entries = query(host, **kwargs) or return nil
|
|
101
|
+
entry = entries.find { |e| e.suffix == 0x20 && e.unique? }
|
|
102
|
+
entry&.name
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def self.entries_from(response)
|
|
106
|
+
response.node_names.map do |n|
|
|
107
|
+
Entry.new(
|
|
108
|
+
n.netbios_name.to_s.rstrip,
|
|
109
|
+
n.suffix.to_i,
|
|
110
|
+
n.group?,
|
|
111
|
+
n.active?
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
private_class_method :entries_from
|
|
116
|
+
|
|
117
|
+
# Accept a reply only when it comes from the queried address. A UDP
|
|
118
|
+
# socket's `#recvfrom` always returns the sender as
|
|
119
|
+
# `[address_family, port, host, numeric_host]`, so the numeric address
|
|
120
|
+
# lives at `addr[3]`, which is compared against the normalized target.
|
|
121
|
+
# Any datagram whose source cannot be determined (not an Array, or a nil
|
|
122
|
+
# numeric address) is rejected — an unverifiable source is untrusted.
|
|
123
|
+
def self.source_matches?(addr, expected_address)
|
|
124
|
+
source = addr.is_a?(Array) ? addr[3] : nil
|
|
125
|
+
return false if source.nil?
|
|
126
|
+
|
|
127
|
+
source_address = begin
|
|
128
|
+
IPAddr.new(source).native
|
|
129
|
+
rescue IPAddr::InvalidAddressError
|
|
130
|
+
return false
|
|
131
|
+
end
|
|
132
|
+
source_address == expected_address
|
|
133
|
+
end
|
|
134
|
+
private_class_method :source_matches?
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
require 'ruby_smb/nbss/name_service_header_flags'
|
|
2
|
+
|
|
3
|
+
module RubySMB
|
|
4
|
+
module Nbss
|
|
5
|
+
# NetBIOS Name Service (NBNS) Node Status Request packet, as defined in
|
|
6
|
+
# [RFC 1002 4.2.17](https://tools.ietf.org/html/rfc1002#section-4.2.17).
|
|
7
|
+
# Sent over UDP to port 137 to retrieve a host's NetBIOS name table.
|
|
8
|
+
class NodeStatusRequest < BinData::Record
|
|
9
|
+
# NBSTAT question type, RFC 1002 4.2.1.3.
|
|
10
|
+
QUESTION_TYPE_NBSTAT = 0x0021
|
|
11
|
+
# Internet class.
|
|
12
|
+
QUESTION_CLASS_IN = 0x0001
|
|
13
|
+
# RFC 1002 4.2.17: a node status query always asks for the wildcard name,
|
|
14
|
+
# 16 bytes of 0x2A ('*') padded with 0x00.
|
|
15
|
+
WILDCARD_NAME = '*'.ljust(16, "\x00").freeze
|
|
16
|
+
|
|
17
|
+
endian :big
|
|
18
|
+
|
|
19
|
+
# 12-byte NBNS header (RFC 1002 4.2.1.1 and 4.2.1.2). The second word is
|
|
20
|
+
# R | OPCODE | NM_FLAGS | RCODE.
|
|
21
|
+
uint16 :transaction_id, label: 'Transaction ID'
|
|
22
|
+
bit1 :response, label: 'Response', initial_value: 0
|
|
23
|
+
bit4 :opcode, label: 'Opcode', initial_value: 0
|
|
24
|
+
name_service_header_flags :nm_flags, label: 'Flags'
|
|
25
|
+
bit4 :rcode, label: 'Result Code', initial_value: 0
|
|
26
|
+
uint16 :qdcount, label: 'QDCount', initial_value: 1
|
|
27
|
+
uint16 :ancount, label: 'ANCount', initial_value: 0
|
|
28
|
+
uint16 :nscount, label: 'NSCount', initial_value: 0
|
|
29
|
+
uint16 :arcount, label: 'ARCount', initial_value: 0
|
|
30
|
+
|
|
31
|
+
# Question section. For a node status query this is always the wildcard
|
|
32
|
+
# NetBIOS name, L1-encoded.
|
|
33
|
+
netbios_name :question_name, label: 'Question Name', initial_value: WILDCARD_NAME
|
|
34
|
+
uint16 :question_type, label: 'Question Type', initial_value: QUESTION_TYPE_NBSTAT
|
|
35
|
+
uint16 :question_class, label: 'Question Class', initial_value: QUESTION_CLASS_IN
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
require 'ruby_smb/nbss/name_service_header_flags'
|
|
2
|
+
|
|
3
|
+
module RubySMB
|
|
4
|
+
module Nbss
|
|
5
|
+
# The NAME_FLAGS field for each Node Status Response name entry.
|
|
6
|
+
class NodeStatusNameFlags < BinData::Record
|
|
7
|
+
endian :big
|
|
8
|
+
|
|
9
|
+
bit1 :group, label: 'Group Name', initial_value: 0
|
|
10
|
+
bit2 :owner_node_type, label: 'Owner Node Type', initial_value: 0
|
|
11
|
+
bit1 :deregister, label: 'Deregister', initial_value: 0
|
|
12
|
+
bit1 :conflict, label: 'Conflict', initial_value: 0
|
|
13
|
+
bit1 :active, label: 'Active', initial_value: 0
|
|
14
|
+
bit1 :permanent, label: 'Permanent', initial_value: 0
|
|
15
|
+
bit9 :reserved, label: 'Reserved', initial_value: 0
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Single entry in the NODE_NAME_ARRAY of a Node Status Response,
|
|
19
|
+
# as defined in [RFC 1002 4.2.18](https://tools.ietf.org/html/rfc1002#section-4.2.18).
|
|
20
|
+
# Fixed 18-byte layout (15-byte name, 1-byte suffix, 16-bit flags).
|
|
21
|
+
class NodeStatusName < BinData::Record
|
|
22
|
+
# NAME_FLAGS bits (RFC 1002 4.2.18).
|
|
23
|
+
GROUP_BIT = 0x8000 # 1 = group name, 0 = unique name
|
|
24
|
+
ACTIVE_BIT = 0x0400 # 1 = name registered
|
|
25
|
+
|
|
26
|
+
endian :big
|
|
27
|
+
|
|
28
|
+
string :netbios_name, label: 'NetBIOS Name', length: 15
|
|
29
|
+
uint8 :suffix, label: 'Suffix'
|
|
30
|
+
node_status_name_flags :name_flags, label: 'Name Flags'
|
|
31
|
+
|
|
32
|
+
def group?
|
|
33
|
+
(raw_name_flags & GROUP_BIT) != 0
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def unique?
|
|
37
|
+
!group?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def active?
|
|
41
|
+
(raw_name_flags & ACTIVE_BIT) != 0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# The 16-bit NAME_FLAGS field as a raw integer, for masking against
|
|
47
|
+
# the GROUP_BIT / ACTIVE_BIT constants.
|
|
48
|
+
def raw_name_flags
|
|
49
|
+
name_flags.to_binary_s.unpack1('n')
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# NetBIOS Name Service (NBNS) Node Status Response packet, as defined in
|
|
54
|
+
# [RFC 1002 4.2.18](https://tools.ietf.org/html/rfc1002#section-4.2.18).
|
|
55
|
+
# Received over UDP from port 137 in reply to a {NodeStatusRequest}.
|
|
56
|
+
# Does not decode the trailing STATISTICS field; callers only need the
|
|
57
|
+
# name table.
|
|
58
|
+
class NodeStatusResponse < BinData::Record
|
|
59
|
+
endian :big
|
|
60
|
+
|
|
61
|
+
# 12-byte NBNS header. The second word is R | OPCODE | NM_FLAGS | RCODE.
|
|
62
|
+
uint16 :transaction_id, label: 'Transaction ID'
|
|
63
|
+
bit1 :response, label: 'Response', initial_value: 0
|
|
64
|
+
bit4 :opcode, label: 'Opcode', initial_value: 0
|
|
65
|
+
name_service_header_flags :nm_flags, label: 'Flags'
|
|
66
|
+
bit4 :rcode, label: 'Result Code', initial_value: 0
|
|
67
|
+
uint16 :qdcount, label: 'QDCount'
|
|
68
|
+
uint16 :ancount, label: 'ANCount'
|
|
69
|
+
uint16 :nscount, label: 'NSCount'
|
|
70
|
+
uint16 :arcount, label: 'ARCount'
|
|
71
|
+
|
|
72
|
+
# Answer section. Microsoft's implementation omits the question-echo,
|
|
73
|
+
# so the owner name appears directly after the header.
|
|
74
|
+
netbios_name :owner_name, label: 'Owner Name'
|
|
75
|
+
uint16 :rr_type, label: 'RR Type'
|
|
76
|
+
uint16 :rr_class, label: 'RR Class'
|
|
77
|
+
uint32 :ttl, label: 'TTL'
|
|
78
|
+
uint16 :rdlength, label: 'RDLENGTH'
|
|
79
|
+
|
|
80
|
+
# RDATA begins here. NODE_NAME_ARRAY is preceded by an 8-bit count.
|
|
81
|
+
uint8 :num_names, label: 'Number of Names'
|
|
82
|
+
array :node_names, type: :node_status_name, initial_length: :num_names
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
data/lib/ruby_smb/nbss.rb
CHANGED
|
@@ -9,9 +9,20 @@ module RubySMB
|
|
|
9
9
|
RETARGET_SESSION_RESPONSE = 0x84
|
|
10
10
|
SESSION_KEEP_ALIVE = 0x85
|
|
11
11
|
|
|
12
|
+
# NBSS negative session response error codes (RFC 1002 section 4.3.6)
|
|
13
|
+
NOT_LISTENING_ON_CALLED_NAME = 0x80
|
|
14
|
+
NOT_LISTENING_FOR_CALLING_NAME = 0x81
|
|
15
|
+
CALLED_NAME_NOT_PRESENT = 0x82
|
|
16
|
+
CALLED_NAME_INSUFFICIENT_RESOURCES = 0x83
|
|
17
|
+
UNSPECIFIED_ERROR = 0x8F
|
|
18
|
+
|
|
12
19
|
require 'ruby_smb/nbss/netbios_name'
|
|
13
20
|
require 'ruby_smb/nbss/session_header'
|
|
14
21
|
require 'ruby_smb/nbss/session_request'
|
|
15
22
|
require 'ruby_smb/nbss/negative_session_response'
|
|
23
|
+
require 'ruby_smb/nbss/name_service_header_flags'
|
|
24
|
+
require 'ruby_smb/nbss/node_status_request'
|
|
25
|
+
require 'ruby_smb/nbss/node_status_response'
|
|
26
|
+
require 'ruby_smb/nbss/node_status'
|
|
16
27
|
end
|
|
17
28
|
end
|
data/lib/ruby_smb/version.rb
CHANGED
data/ruby_smb.gemspec
CHANGED
|
@@ -43,6 +43,9 @@ Gem::Specification.new do |spec|
|
|
|
43
43
|
spec.add_runtime_dependency 'rubyntlm', '>= 0.6.5'
|
|
44
44
|
spec.add_runtime_dependency 'windows_error', '>= 0.1.4'
|
|
45
45
|
spec.add_runtime_dependency 'bindata', '2.4.15'
|
|
46
|
+
# 0.12 introduced the model `wrapper` DSL the SPNEGO types rely on; the upper Ruby versions resolve to a
|
|
47
|
+
# newer rasn1, while Ruby 2.7 caps at 0.13.1 (0.14+ needs Ruby 3.0), and both are known good.
|
|
48
|
+
spec.add_runtime_dependency 'rasn1', '>= 0.12'
|
|
46
49
|
spec.add_runtime_dependency 'openssl-ccm'
|
|
47
50
|
spec.add_runtime_dependency 'openssl-cmac'
|
|
48
51
|
end
|
|
@@ -742,6 +742,28 @@ RSpec.describe RubySMB::Client do
|
|
|
742
742
|
allow(RubySMB::Nbss::SessionHeader).to receive(:read).and_raise(IOError)
|
|
743
743
|
expect { client.session_request }.to raise_error(RubySMB::Error::InvalidPacket)
|
|
744
744
|
end
|
|
745
|
+
|
|
746
|
+
describe 'NetBiosSessionService error propagates the error_code' do
|
|
747
|
+
it 'attaches the numeric NBSS error code to the raised exception' do
|
|
748
|
+
negative = RubySMB::Nbss::NegativeSessionResponse.new
|
|
749
|
+
negative.session_header.session_packet_type = RubySMB::Nbss::NEGATIVE_SESSION_RESPONSE
|
|
750
|
+
negative.error_code = RubySMB::Nbss::NOT_LISTENING_ON_CALLED_NAME
|
|
751
|
+
allow(dispatcher).to receive(:recv_packet).and_return(negative.to_binary_s)
|
|
752
|
+
expect { client.session_request('OTHERNAME') }.to raise_error(RubySMB::Error::NetBiosSessionService) do |e|
|
|
753
|
+
expect(e.error_code.to_i).to eq(RubySMB::Nbss::NOT_LISTENING_ON_CALLED_NAME)
|
|
754
|
+
end
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
it 'reports CALLED_NAME_NOT_PRESENT so the caller can resolve and retry' do
|
|
758
|
+
negative = RubySMB::Nbss::NegativeSessionResponse.new
|
|
759
|
+
negative.session_header.session_packet_type = RubySMB::Nbss::NEGATIVE_SESSION_RESPONSE
|
|
760
|
+
negative.error_code = RubySMB::Nbss::CALLED_NAME_NOT_PRESENT
|
|
761
|
+
allow(dispatcher).to receive(:recv_packet).and_return(negative.to_binary_s)
|
|
762
|
+
expect { client.session_request }.to raise_error(RubySMB::Error::NetBiosSessionService) do |e|
|
|
763
|
+
expect(e.error_code.to_i).to eq(RubySMB::Nbss::CALLED_NAME_NOT_PRESENT)
|
|
764
|
+
end
|
|
765
|
+
end
|
|
766
|
+
end
|
|
745
767
|
end
|
|
746
768
|
|
|
747
769
|
describe '#session_request_packet' do
|
|
@@ -776,6 +798,7 @@ RSpec.describe RubySMB::Client do
|
|
|
776
798
|
expect(client.session_request_packet.called_name).to eq('*SMBSERVER ')
|
|
777
799
|
end
|
|
778
800
|
end
|
|
801
|
+
|
|
779
802
|
end
|
|
780
803
|
|
|
781
804
|
context 'Protocol Negotiation' do
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
RSpec.describe RubySMB::Gss::Provider::Kerberos do
|
|
2
|
+
let(:server_client) { double('server_client', logger: Logger.new(IO::NULL)) }
|
|
3
|
+
# opaque stand-in for a real AP-REQ; this provider never interprets the payload
|
|
4
|
+
let(:ap_req) { "\x6e\x82\x01\x0a".b + Random.new(1).bytes(64) }
|
|
5
|
+
let(:mech_token) { RubySMB::Gss::Provider::Kerberos::TOK_ID_KRB_AP_REQ + ap_req }
|
|
6
|
+
|
|
7
|
+
subject(:provider) { RubySMB::Gss::Provider::Kerberos.new }
|
|
8
|
+
|
|
9
|
+
describe '#mech_types' do
|
|
10
|
+
it 'advertises both the standard and the Microsoft Kerberos mechanism' do
|
|
11
|
+
expect(provider.mech_types.map(&:oid)).to eq(
|
|
12
|
+
[RubySMB::Gss::OID_KERBEROS_5.oid, RubySMB::Gss::OID_MICROSOFT_KERBEROS_5.oid]
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'reports support for both' do
|
|
17
|
+
expect(provider.supports_mech_type?(RubySMB::Gss::OID_KERBEROS_5)).to be true
|
|
18
|
+
expect(provider.supports_mech_type?(RubySMB::Gss::OID_MICROSOFT_KERBEROS_5)).to be true
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'does not report support for other mechanisms' do
|
|
22
|
+
expect(provider.supports_mech_type?(RubySMB::Gss::OID_NTLMSSP)).to be false
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
describe '.token_id' do
|
|
27
|
+
# a GSS-API InitialContextToken, shaped as a Windows client actually sends one: the token id follows the
|
|
28
|
+
# mechanism OID rather than starting the token, and the framing around it is not valid ASN.1
|
|
29
|
+
let(:initial_context_token) do
|
|
30
|
+
"\x60\x82\x0c\x0e".b +
|
|
31
|
+
OpenSSL::ASN1::ObjectId.new('1.2.840.113554.1.2.2').to_der +
|
|
32
|
+
RubySMB::Gss::Provider::Kerberos::TOK_ID_KRB_AP_REQ +
|
|
33
|
+
"\x6e\x82\x0b\xfd".b
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'reads the identifier from past the mechanism OID' do
|
|
37
|
+
expect(RubySMB::Gss::Provider::Kerberos.token_id(initial_context_token))
|
|
38
|
+
.to eq(RubySMB::Gss::Provider::Kerberos::TOK_ID_KRB_AP_REQ)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'handles a short form length' do
|
|
42
|
+
short = "\x60\x14".b + OpenSSL::ASN1::ObjectId.new('1.2.840.113554.1.2.2').to_der +
|
|
43
|
+
RubySMB::Gss::Provider::Kerberos::TOK_ID_KRB_AP_REP + "\x6f\x00".b
|
|
44
|
+
expect(RubySMB::Gss::Provider::Kerberos.token_id(short))
|
|
45
|
+
.to eq(RubySMB::Gss::Provider::Kerberos::TOK_ID_KRB_AP_REP)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'is nil for anything not shaped like an InitialContextToken' do
|
|
49
|
+
expect(RubySMB::Gss::Provider::Kerberos.token_id(nil)).to be_nil
|
|
50
|
+
expect(RubySMB::Gss::Provider::Kerberos.token_id('')).to be_nil
|
|
51
|
+
expect(RubySMB::Gss::Provider::Kerberos.token_id('short')).to be_nil
|
|
52
|
+
# a SEQUENCE rather than an InitialContextToken
|
|
53
|
+
expect(RubySMB::Gss::Provider::Kerberos.token_id("\x30\x82\x00\x05".b)).to be_nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it 'is nil when no mechanism OID follows' do
|
|
57
|
+
expect(RubySMB::Gss::Provider::Kerberos.token_id("\x60\x04\x02\x01\x05\x00".b)).to be_nil
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
describe '#on_mech_token' do
|
|
62
|
+
it 'can be set with a block' do
|
|
63
|
+
provider.on_mech_token { |_token, _authenticator| :handled }
|
|
64
|
+
expect(provider.on_mech_token('token', nil)).to eq(:handled)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'can be set through the constructor' do
|
|
68
|
+
configured = RubySMB::Gss::Provider::Kerberos.new { |_token, _authenticator| :handled }
|
|
69
|
+
expect(configured.on_mech_token('token', nil)).to eq(:handled)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it 'is nil when no handler has been set' do
|
|
73
|
+
expect(provider.on_mech_token('token', nil)).to be_nil
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# referenced explicitly; described_class would resolve to the authenticator inside this group
|
|
78
|
+
describe RubySMB::Gss::Provider::Kerberos::Authenticator do
|
|
79
|
+
subject(:authenticator) { provider.new_authenticator(server_client) }
|
|
80
|
+
|
|
81
|
+
describe '#process' do
|
|
82
|
+
context 'when building the advertisement' do
|
|
83
|
+
it 'offers the Kerberos mechanisms' do
|
|
84
|
+
expect(authenticator.process(nil).buffer).to eq(RubySMB::Gss.gss_neg_token_init(provider.mech_types))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it 'succeeds' do
|
|
88
|
+
expect(authenticator.process(nil).nt_status).to eq(WindowsError::NTStatus::STATUS_SUCCESS)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
context 'with a mechanism token' do
|
|
93
|
+
it 'passes the token to the handler' do
|
|
94
|
+
received = nil
|
|
95
|
+
provider.on_mech_token { |token, _authenticator| received = token; nil }
|
|
96
|
+
authenticator.process(neg_token_init(mech_token))
|
|
97
|
+
expect(received).to eq(mech_token)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it 'does not alter the token, so a forwarded ticket stays valid' do
|
|
101
|
+
received = nil
|
|
102
|
+
provider.on_mech_token { |token, _authenticator| received = token; nil }
|
|
103
|
+
authenticator.process(neg_token_init(mech_token))
|
|
104
|
+
expect(received).to eq(mech_token)
|
|
105
|
+
expect(received[2..]).to eq(ap_req)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'records the token on the authenticator' do
|
|
109
|
+
authenticator.process(neg_token_init(mech_token))
|
|
110
|
+
expect(authenticator.mech_token).to eq(mech_token)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it 'returns whatever the handler decides' do
|
|
114
|
+
expected = RubySMB::Gss::Provider::Result.new(nil, WindowsError::NTStatus::STATUS_SUCCESS)
|
|
115
|
+
provider.on_mech_token { |_token, _authenticator| expected }
|
|
116
|
+
expect(authenticator.process(neg_token_init(mech_token))).to be(expected)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it 'refuses the attempt when no handler is set' do
|
|
120
|
+
# nothing here can validate a ticket, so the attempt must not silently succeed
|
|
121
|
+
result = authenticator.process(neg_token_init(mech_token))
|
|
122
|
+
expect(result.nt_status).to eq(WindowsError::NTStatus::STATUS_LOGON_FAILURE)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it 'refuses the attempt when the handler returns something that is not a Result' do
|
|
126
|
+
# the session setup path calls nt_status on the result, so a non-Result (e.g. a boolean from a naive
|
|
127
|
+
# handler) must be refused here rather than handed on to crash the caller
|
|
128
|
+
provider.on_mech_token { |_token, _authenticator| true }
|
|
129
|
+
result = authenticator.process(neg_token_init(mech_token))
|
|
130
|
+
expect(result).to be_a(RubySMB::Gss::Provider::Result)
|
|
131
|
+
expect(result.nt_status).to eq(WindowsError::NTStatus::STATUS_LOGON_FAILURE)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
it 'accepts a token carried in a continuation' do
|
|
135
|
+
received = nil
|
|
136
|
+
provider.on_mech_token { |token, _authenticator| received = token; nil }
|
|
137
|
+
authenticator.process(RubySMB::Gss.gss_type3(mech_token))
|
|
138
|
+
expect(received).to eq(mech_token)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
context 'with a malformed request' do
|
|
143
|
+
it 'returns nil rather than raising' do
|
|
144
|
+
expect(authenticator.process('not asn1 at all')).to be_nil
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
it 'returns nil when there is no mechanism token' do
|
|
148
|
+
expect(authenticator.process(neg_token_init(nil))).to be_nil
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
describe '#reset!' do
|
|
154
|
+
it 'forgets the recorded token' do
|
|
155
|
+
authenticator.process(neg_token_init(mech_token))
|
|
156
|
+
expect(authenticator.mech_token).to_not be_nil
|
|
157
|
+
authenticator.reset!
|
|
158
|
+
expect(authenticator.mech_token).to be_nil
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
describe 'alongside NTLM' do
|
|
164
|
+
let(:ntlm_provider) { RubySMB::Gss::Provider::NTLM.new.tap { |p| p.put_account('RubySMB', 'password') } }
|
|
165
|
+
let(:multi) { RubySMB::Gss::Provider::Multi.new([provider, ntlm_provider]) }
|
|
166
|
+
|
|
167
|
+
it 'is offered ahead of NTLM' do
|
|
168
|
+
expect(multi.mech_types.map(&:oid)).to eq(
|
|
169
|
+
[
|
|
170
|
+
RubySMB::Gss::OID_KERBEROS_5.oid,
|
|
171
|
+
RubySMB::Gss::OID_MICROSOFT_KERBEROS_5.oid,
|
|
172
|
+
RubySMB::Gss::OID_NTLMSSP.oid
|
|
173
|
+
]
|
|
174
|
+
)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
it 'receives the token when a client selects Kerberos' do
|
|
178
|
+
received = nil
|
|
179
|
+
provider.on_mech_token { |token, _authenticator| received = token; nil }
|
|
180
|
+
multi.new_authenticator(server_client).process(neg_token_init(mech_token))
|
|
181
|
+
expect(received).to eq(mech_token)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
it 'is left alone when a client selects NTLM' do
|
|
185
|
+
received = nil
|
|
186
|
+
provider.on_mech_token { |token, _authenticator| received = token; nil }
|
|
187
|
+
type1 = Net::NTLM::Message::Type1.new.tap { |msg| msg.domain = 'WORKGROUP' }
|
|
188
|
+
result = multi.new_authenticator(server_client).process(RubySMB::Gss.gss_type1(type1.serialize))
|
|
189
|
+
expect(received).to be_nil
|
|
190
|
+
expect(result.nt_status).to eq(WindowsError::NTStatus::STATUS_MORE_PROCESSING_REQUIRED)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Build a SPNEGO NegTokenInit selecting Kerberos and carrying the specified mechanism token.
|
|
195
|
+
def neg_token_init(token)
|
|
196
|
+
inner = [OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::Sequence.new([RubySMB::Gss::OID_KERBEROS_5])], 0, :CONTEXT_SPECIFIC)]
|
|
197
|
+
inner << OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::OctetString.new(token)], 2, :CONTEXT_SPECIFIC) unless token.nil?
|
|
198
|
+
|
|
199
|
+
OpenSSL::ASN1::ASN1Data.new(
|
|
200
|
+
[
|
|
201
|
+
RubySMB::Gss::OID_SPNEGO,
|
|
202
|
+
OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::Sequence.new(inner)], 0, :CONTEXT_SPECIFIC)
|
|
203
|
+
], 0, :APPLICATION
|
|
204
|
+
).to_der
|
|
205
|
+
end
|
|
206
|
+
end
|