ruby_smb 3.3.22 → 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.
@@ -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
@@ -3,8 +3,10 @@ module RubySMB
3
3
  # Extract and store useful information about the peer/server from the
4
4
  # NTLM Type 2 (challenge) TargetInfo fields.
5
5
  #
6
- # @param target_info_str [String] the Target Info string
6
+ # @param target_info_str [String, nil] the Target Info string
7
7
  def store_target_info(target_info_str)
8
+ return if target_info_str.nil? || target_info_str.empty?
9
+
8
10
  target_info = Net::NTLM::TargetInfo.new(target_info_str)
9
11
  {
10
12
  Net::NTLM::TargetInfo::MSV_AV_NB_COMPUTER_NAME => :@default_name,
@@ -1,3 +1,3 @@
1
1
  module RubySMB
2
- VERSION = '3.3.22'.freeze
2
+ VERSION = '3.3.25'.freeze
3
3
  end
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
@@ -1614,6 +1637,15 @@ RSpec.describe RubySMB::Client do
1614
1637
  expect(smb1_client.session_key).to eq ntlm_client.session_key
1615
1638
  end
1616
1639
 
1640
+ it 'continues authentication when the server advertises empty target info' do
1641
+ challenge = Net::NTLM::Message.decode64(type2_string)
1642
+ challenge.set_flag(:TARGET_INFO)
1643
+ challenge.target_info = ''.b
1644
+ allow(smb1_client).to receive(:smb1_type2_message).and_return(challenge.encode64)
1645
+
1646
+ expect(smb1_client.smb1_authenticate).to eq WindowsError::NTStatus::STATUS_SUCCESS
1647
+ end
1648
+
1617
1649
  it 'stores the OS version number from the challenge message' do
1618
1650
  smb1_client.smb1_authenticate
1619
1651
  expect(smb1_client.os_version).to eq '6.1.7601'
@@ -1860,6 +1892,15 @@ RSpec.describe RubySMB::Client do
1860
1892
  expect(smb2_client.session_key).to eq ntlm_client.session_key
1861
1893
  end
1862
1894
 
1895
+ it 'continues authentication when the server advertises empty target info' do
1896
+ challenge = Net::NTLM::Message.decode64(type2_string)
1897
+ challenge.set_flag(:TARGET_INFO)
1898
+ challenge.target_info = ''.b
1899
+ allow(smb2_client).to receive(:smb2_type2_message).and_return(challenge.encode64)
1900
+
1901
+ expect(smb2_client.smb2_authenticate).to eq WindowsError::NTStatus::STATUS_SUCCESS
1902
+ end
1903
+
1863
1904
  it 'stores the OS version number from the challenge message' do
1864
1905
  smb2_client.smb2_authenticate
1865
1906
  expect(smb2_client.os_version).to eq '6.1.7601'
@@ -2068,6 +2109,25 @@ RSpec.describe RubySMB::Client do
2068
2109
  client.store_target_info(target_info_str)
2069
2110
  end
2070
2111
 
2112
+ it 'preserves existing peer information when given empty target info' do
2113
+ client.store_target_info(target_info_str)
2114
+ expect { client.store_target_info(''.b) }.to_not change {
2115
+ [client.default_name, client.default_domain, client.dns_host_name, client.dns_domain_name, client.dns_tree_name]
2116
+ }
2117
+ end
2118
+
2119
+ it 'accepts nil target info' do
2120
+ expect { client.store_target_info(nil) }.to_not raise_error
2121
+ end
2122
+
2123
+ it 'accepts empty target info encoded with an end-of-list marker' do
2124
+ expect { client.store_target_info("\x00\x00\x00\x00".b) }.to_not raise_error
2125
+ end
2126
+
2127
+ it 'rejects nonempty invalid target info' do
2128
+ expect { client.store_target_info("\xff\xff\x00\x00".b) }.to raise_error(Net::NTLM::InvalidTargetDataError)
2129
+ end
2130
+
2071
2131
  it 'sets the expected Client\'s attribute' do
2072
2132
  client.store_target_info(target_info_str)
2073
2133
  expect(client.default_name).to eq 'TESTNAME'
@@ -173,6 +173,14 @@ RSpec.describe RubySMB::Dcerpc::Client do
173
173
  expect(auth_client.process_ntlm_type2(type2_message)).to start_with("NTLMSSP\x00\x03\x00\x00\x00")
174
174
  end
175
175
 
176
+ it 'returns a type3 message when the server advertises empty target info' do
177
+ challenge = Net::NTLM::Message.parse(type2_message)
178
+ challenge.set_flag(:TARGET_INFO)
179
+ challenge.target_info = ''.b
180
+
181
+ expect(auth_client.process_ntlm_type2(challenge.serialize)).to start_with("NTLMSSP\x00\x03\x00\x00\x00")
182
+ end
183
+
176
184
  it 'stores the session key' do
177
185
  auth_client.process_ntlm_type2(type2_message)
178
186
  expect(auth_client.instance_variable_get(:@session_key).size).to eq(16)
@@ -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