diff --git a/lib/ruby_smb/gss.rb b/lib/ruby_smb/gss.rb index 3dcbf4782..5c8cf8bf5 100644 --- a/lib/ruby_smb/gss.rb +++ b/lib/ruby_smb/gss.rb @@ -7,6 +7,11 @@ module Gss OID_SPNEGO = OpenSSL::ASN1::ObjectId.new('1.3.6.1.5.5.2') OID_NEGOEX = OpenSSL::ASN1::ObjectId.new('1.3.6.1.4.1.311.2.2.30') OID_NTLMSSP = OpenSSL::ASN1::ObjectId.new('1.3.6.1.4.1.311.2.2.10') + # The Kerberos v5 GSS-API mechanism (RFC 4121). Microsoft's SPNEGO + # implementation also uses a legacy OID that differs by a single arc, and + # clients may offer or select either, so both are defined here. + OID_KERBEROS_5 = OpenSSL::ASN1::ObjectId.new('1.2.840.113554.1.2.2') + OID_MICROSOFT_KERBEROS_5 = OpenSSL::ASN1::ObjectId.new('1.2.840.48018.1.2.2') # Allow safe navigation of a decoded ASN.1 data structure. Similar to Ruby's # builtin Hash#dig method but using the #value attribute of each ASN object. @@ -46,6 +51,38 @@ def self.asn1encode(str = '') encoded_string end + # Build the SPNEGO NegTokenInit that a server sends to advertise the + # authentication mechanisms it supports, per RFC 4178 section 4.2.1. + # + # The mechTypes list is supplied by the caller so that it reflects every + # mechanism the server actually offers, rather than being fixed to a single + # mechanism by whichever provider happens to build the token. + # + # @param [Array] mech_types the mechanisms to + # advertise, in preference order (most preferred first). + # @return [String] the DER encoded NegTokenInit. + def self.gss_neg_token_init(mech_types) + raise ArgumentError, 'at least one mechanism must be advertised' if mech_types.nil? || mech_types.empty? + + OpenSSL::ASN1::ASN1Data.new([ + OID_SPNEGO, + OpenSSL::ASN1::ASN1Data.new([ + OpenSSL::ASN1::Sequence.new([ + OpenSSL::ASN1::ASN1Data.new([ + OpenSSL::ASN1::Sequence.new(mech_types) + ], 0, :CONTEXT_SPECIFIC), + OpenSSL::ASN1::ASN1Data.new([ + OpenSSL::ASN1::ASN1Data.new([ + OpenSSL::ASN1::ASN1Data.new([ + OpenSSL::ASN1::GeneralString.new('not_defined_in_RFC4178@please_ignore') + ], 0, :CONTEXT_SPECIFIC) + ], 16, :UNIVERSAL) + ], 3, :CONTEXT_SPECIFIC) + ]) + ], 0, :CONTEXT_SPECIFIC) + ], 0, :APPLICATION).to_der + end + # Create a GSS Security Blob of an NTLM Type 1 Message. def self.gss_type1(type1) OpenSSL::ASN1::ASN1Data.new([ diff --git a/lib/ruby_smb/gss/provider.rb b/lib/ruby_smb/gss/provider.rb index 6a59c3cb0..3f81d9374 100644 --- a/lib/ruby_smb/gss/provider.rb +++ b/lib/ruby_smb/gss/provider.rb @@ -26,6 +26,26 @@ def new_authenticator(server_client) raise NotImplementedError end + # + # The GSS mechanisms this provider can handle, in preference order. These are advertised to the client in the + # SPNEGO NegTokenInit, and are used to route an incoming token to the provider that understands it. + # + # @return [Array] + def mech_types + raise NotImplementedError + end + + # + # Whether this provider can handle a token for the specified mechanism. + # + # @param [OpenSSL::ASN1::ObjectId] mech_type the mechanism selected by the client + # @return [Boolean] + def supports_mech_type?(mech_type) + return false if mech_type.nil? + + mech_types.any? { |oid| oid.oid == mech_type.oid } + end + # # Whether or not anonymous authentication attempts should be permitted. # @@ -42,3 +62,5 @@ def new_authenticator(server_client) require 'ruby_smb/gss/provider/authenticator' require 'ruby_smb/gss/provider/ntlm' +require 'ruby_smb/gss/provider/kerberos' +require 'ruby_smb/gss/provider/multi' diff --git a/lib/ruby_smb/gss/provider/kerberos.rb b/lib/ruby_smb/gss/provider/kerberos.rb new file mode 100644 index 000000000..8c8866173 --- /dev/null +++ b/lib/ruby_smb/gss/provider/kerberos.rb @@ -0,0 +1,152 @@ +module RubySMB + module Gss + module Provider + # + # A GSS provider that advertises Kerberos and surfaces the mechanism token a client sends, without interpreting + # it. + # + # A Kerberos AP-REQ is encrypted to the service the client believes it is talking to, so a server that does not + # hold that service's key cannot read it. This provider therefore does not attempt to: it hands the token to a + # handler and lets that decide what to tell the client. That is enough for a server to observe or forward + # Kerberos authentication, and it keeps Kerberos message parsing out of this library entirely. + # + # Accepting Kerberos properly, by decrypting the ticket with a service key and validating the PAC, is a separate + # concern and is not implemented here. + # + # The token handed to the handler is the mechanism token exactly as the client sent it. For Kerberos that is a + # GSS-API InitialContextToken (RFC 2743 section 3.1), which wraps the mechanism OID and the token identifier + # around the Kerberos message: + # + # 60 82 0c 0e InitialContextToken + # 06 09 2a 86 48 .. the mechanism OID + # 01 00 the token id, here KRB_AP_REQ + # 6e 82 0b fd .. the AP-REQ itself + # + # Note that the token id follows the OID rather than starting the token, and that the framing around it is not + # valid ASN.1, so OpenSSL::ASN1.decode will not parse it. {.token_id} reads it without decoding the payload. + # + # @example Capture the token a client sends + # provider = RubySMB::Gss::Provider::Kerberos.new + # provider.on_mech_token do |token, authenticator| + # RubySMB::Gss::Provider::Kerberos.token_id(token) == RubySMB::Gss::Provider::Kerberos::TOK_ID_KRB_AP_REQ + # RubySMB::Gss::Provider::Result.new(nil, WindowsError::NTStatus::STATUS_LOGON_FAILURE) + # end + # + class Kerberos < Base + # The GSS token identifiers that may appear in a Kerberos mechanism token, per RFC 4121 section 4.1. They are + # provided so a handler can tell the messages apart without decoding the payload. + TOK_ID_KRB_AP_REQ = "\x01\x00".b.freeze + TOK_ID_KRB_AP_REP = "\x02\x00".b.freeze + TOK_ID_KRB_ERROR = "\x03\x00".b.freeze + + # + # Read the token identifier out of a GSS-API InitialContextToken, so a handler can tell an AP-REQ from an + # AP-REP or a KRB-ERROR. The identifier follows the mechanism OID rather than starting the token, and the + # framing is not valid ASN.1, so it is located by walking the lengths rather than by decoding. + # + # @param [String] token the mechanism token as received + # @return [String, nil] the two byte identifier, or nil if the token is not shaped as expected + def self.token_id(token) + return nil if token.nil? || token.bytesize < 4 || token.getbyte(0) != 0x60 + + length_byte = token.getbyte(1) + # a long form length says how many bytes carry the length, a short form is the length itself + offset = length_byte > 0x80 ? 2 + (length_byte & 0x7f) : 2 + return nil if token.getbyte(offset) != 0x06 # the mechanism OID must follow + + offset += 2 + token.getbyte(offset + 1) + token.byteslice(offset, 2) + end + + # @param [Proc, nil] block an optional handler for received mechanism tokens, see {#on_mech_token}. + def initialize(&block) + @on_mech_token = block + @allow_anonymous = false + @allow_guests = false + end + + def new_authenticator(server_client) + Authenticator.new(self, server_client) + end + + def mech_types + # both are advertised because Microsoft clients may select either + [Gss::OID_KERBEROS_5, Gss::OID_MICROSOFT_KERBEROS_5] + end + + # + # Set or invoke the handler called when a client sends a Kerberos mechanism token. + # + # The handler receives the opaque token and the authenticator that received it, and returns the {Result} to + # reply with. When no handler is set the authentication attempt is rejected, since this provider cannot + # validate a ticket on its own. + # + # @param [String] token the mechanism token, as sent by the client + # @param [Authenticator] authenticator the authenticator that received it + # @return [Result, nil] + def on_mech_token(token=nil, authenticator=nil, &block) + if block.nil? + return nil if @on_mech_token.nil? + + @on_mech_token.call(token, authenticator) + else + @on_mech_token = block + end + end + + class Authenticator < Authenticator::Base + def reset! + super + @mech_token = nil + end + + # @return [String, nil] the most recent mechanism token received from the client. + attr_reader :mech_token + + def process(request_buffer=nil) + if request_buffer.nil? + return Result.new(Gss.gss_neg_token_init(@provider.mech_types), WindowsError::NTStatus::STATUS_SUCCESS) + end + + begin + gss_api = OpenSSL::ASN1.decode(request_buffer) + rescue OpenSSL::ASN1::ASN1Error => e + logger.error("Failed to parse the ASN1-encoded authentication request (#{e.message})") + return + end + + token = extract_mech_token(gss_api) + if token.nil? + logger.warn('Received a Kerberos request carrying no mechanism token') + return + end + + @mech_token = token + result = @provider.on_mech_token(token, self) + # with no handler there is nothing that can validate the ticket, so the attempt is refused rather than + # silently succeeding + result || Result.new(nil, WindowsError::NTStatus::STATUS_LOGON_FAILURE) + end + + private + + # + # Pull the mechanism token out of a SPNEGO NegTokenInit or NegTokenResp. The token is returned exactly as the + # client sent it, so a caller that forwards it elsewhere does not alter the ticket it contains. + # + # @param gss_api the decoded request + # @return [String, nil] + def extract_mech_token(gss_api) + if gss_api&.tag == 0 && gss_api&.tag_class == :APPLICATION + # NegTokenInit: mechTypes then the mechToken + Gss.asn1dig(gss_api, 1, 0, 1, 0)&.value + elsif gss_api&.tag == 1 && gss_api&.tag_class == :CONTEXT_SPECIFIC + # NegTokenResp: the responseToken, tagged 2, carries the continuation + Hash[Gss.asn1dig(gss_api, 0)&.value.to_a.map { |obj| [obj.tag, obj.value[0].value] }][2] + end + end + end + end + end + end +end diff --git a/lib/ruby_smb/gss/provider/multi.rb b/lib/ruby_smb/gss/provider/multi.rb new file mode 100644 index 000000000..5584ad7d0 --- /dev/null +++ b/lib/ruby_smb/gss/provider/multi.rb @@ -0,0 +1,129 @@ +module RubySMB + module Gss + module Provider + # + # A GSS provider that offers more than one authentication mechanism to the client and routes each request to + # whichever of its sub-providers understands the mechanism the client selected. + # + # SPNEGO exists so that a client and server can agree on a mechanism, but a server that only ever advertises one + # has nothing to negotiate. This provider advertises the mechanisms of every provider it holds, in the order they + # were given, so a client can pick the one it prefers. + # + # @example Offer Kerberos, falling back to NTLM + # provider = RubySMB::Gss::Provider::Multi.new([kerberos_provider, ntlm_provider]) + # RubySMB::Server.new(gss_provider: provider) + # + class Multi < Base + # + # @param [Array] providers the providers to offer, in preference order (most preferred first). + def initialize(providers) + raise ArgumentError, 'at least one provider is required' if providers.nil? || providers.empty? + + @providers = providers.dup.freeze + end + + # @return [Array] the providers this instance will route between. + attr_reader :providers + + def new_authenticator(server_client) + Authenticator.new(self, server_client) + end + + # + # Every mechanism offered by every provider, in provider order, with duplicates removed so a mechanism supported + # by two providers is only advertised once. + # + # @return [Array] + def mech_types + @providers.flat_map(&:mech_types).uniq(&:oid) + end + + # + # The first provider that handles the specified mechanism, or nil if none do. + # + # @param [OpenSSL::ASN1::ObjectId] mech_type the mechanism selected by the client + # @return [Provider::Base, nil] + def provider_for(mech_type) + @providers.find { |provider| provider.supports_mech_type?(mech_type) } + end + + def allow_anonymous + @providers.any?(&:allow_anonymous) + end + + def allow_guests + @providers.any?(&:allow_guests) + end + + class Authenticator < Authenticator::Base + def initialize(provider, server_client) + # built lazily, so a provider that is advertised but never selected is never instantiated + @authenticators = {} + @selected = nil + super + end + + def reset! + super + @authenticators&.each_value(&:reset!) + @selected = nil + end + + def process(request_buffer=nil) + # the advertisement, listing every mechanism the server is willing to accept + return Result.new(Gss.gss_neg_token_init(@provider.mech_types), WindowsError::NTStatus::STATUS_SUCCESS) if request_buffer.nil? + + begin + gss_api = OpenSSL::ASN1.decode(request_buffer) + rescue OpenSSL::ASN1::ASN1Error => e + logger.error("Failed to parse the ASN1-encoded authentication request (#{e.message})") + return + end + + if negotiation_init?(gss_api) + # a NegTokenInit names the mechanism the client chose, so this is where routing is decided + mech_type = Gss.asn1dig(gss_api, 1, 0, 0, 0, 0) + authenticator = authenticator_for(mech_type) + if authenticator.nil? + logger.warn("Client selected an unsupported GSS mechanism (#{mech_type&.oid || 'unknown'})") + return + end + + @selected = authenticator + elsif @selected.nil? + # a NegTokenResp carries no mechanism OID, so it can only be interpreted as a continuation of a + # negotiation that has already selected one + logger.warn('Received a GSS continuation token before any mechanism was selected') + return + end + + @selected.process(request_buffer) + end + + # The session key belongs to whichever mechanism actually authenticated the client. + def session_key + @selected&.session_key + end + + def session_key=(value) + @selected&.session_key = value + end + + private + + # Whether the token is a NegTokenInit, which is the only token that names a mechanism. + def negotiation_init?(gss_api) + gss_api&.tag == 0 && gss_api&.tag_class == :APPLICATION + end + + def authenticator_for(mech_type) + provider = @provider.provider_for(mech_type) + return nil if provider.nil? + + @authenticators[provider] ||= provider.new_authenticator(@server_client) + end + end + end + end + end +end diff --git a/lib/ruby_smb/gss/provider/ntlm.rb b/lib/ruby_smb/gss/provider/ntlm.rb index 5f774f253..08b408c46 100644 --- a/lib/ruby_smb/gss/provider/ntlm.rb +++ b/lib/ruby_smb/gss/provider/ntlm.rb @@ -26,26 +26,7 @@ def reset! def process(request_buffer=nil) if request_buffer.nil? - # this is only NTLMSSP (as opposed to SPNEGO + NTLMSSP) - buffer = OpenSSL::ASN1::ASN1Data.new([ - Gss::OID_SPNEGO, - OpenSSL::ASN1::ASN1Data.new([ - OpenSSL::ASN1::Sequence.new([ - OpenSSL::ASN1::ASN1Data.new([ - OpenSSL::ASN1::Sequence.new([ - Gss::OID_NTLMSSP - ]) - ], 0, :CONTEXT_SPECIFIC), - OpenSSL::ASN1::ASN1Data.new([ - OpenSSL::ASN1::ASN1Data.new([ - OpenSSL::ASN1::ASN1Data.new([ - OpenSSL::ASN1::GeneralString.new('not_defined_in_RFC4178@please_ignore') - ], 0, :CONTEXT_SPECIFIC) - ], 16, :UNIVERSAL) - ], 3, :CONTEXT_SPECIFIC) - ]) - ], 0, :CONTEXT_SPECIFIC) - ], 0, :APPLICATION).to_der + buffer = Gss.gss_neg_token_init(@provider.mech_types) return Result.new(buffer, WindowsError::NTStatus::STATUS_SUCCESS) end @@ -293,6 +274,10 @@ def new_authenticator(server_client) Authenticator.new(self, server_client) end + def mech_types + [Gss::OID_NTLMSSP] + end + # # Lookup and return an account based on the username and optionally, the domain. If no domain is specified or # or it is the special value '.', the default domain will be used. The username and domain values are case diff --git a/spec/lib/ruby_smb/gss/provider/kerberos_spec.rb b/spec/lib/ruby_smb/gss/provider/kerberos_spec.rb new file mode 100644 index 000000000..4dfdd6307 --- /dev/null +++ b/spec/lib/ruby_smb/gss/provider/kerberos_spec.rb @@ -0,0 +1,197 @@ +RSpec.describe RubySMB::Gss::Provider::Kerberos do + let(:server_client) { double('server_client', logger: Logger.new(IO::NULL)) } + # opaque stand-in for a real AP-REQ; this provider never interprets the payload + let(:ap_req) { "\x6e\x82\x01\x0a".b + Random.new(1).bytes(64) } + let(:mech_token) { RubySMB::Gss::Provider::Kerberos::TOK_ID_KRB_AP_REQ + ap_req } + + subject(:provider) { RubySMB::Gss::Provider::Kerberos.new } + + describe '#mech_types' do + it 'advertises both the standard and the Microsoft Kerberos mechanism' do + expect(provider.mech_types.map(&:oid)).to eq( + [RubySMB::Gss::OID_KERBEROS_5.oid, RubySMB::Gss::OID_MICROSOFT_KERBEROS_5.oid] + ) + end + + it 'reports support for both' do + expect(provider.supports_mech_type?(RubySMB::Gss::OID_KERBEROS_5)).to be true + expect(provider.supports_mech_type?(RubySMB::Gss::OID_MICROSOFT_KERBEROS_5)).to be true + end + + it 'does not report support for other mechanisms' do + expect(provider.supports_mech_type?(RubySMB::Gss::OID_NTLMSSP)).to be false + end + end + + describe '.token_id' do + # a GSS-API InitialContextToken, shaped as a Windows client actually sends one: the token id follows the + # mechanism OID rather than starting the token, and the framing around it is not valid ASN.1 + let(:initial_context_token) do + "\x60\x82\x0c\x0e".b + + OpenSSL::ASN1::ObjectId.new('1.2.840.113554.1.2.2').to_der + + RubySMB::Gss::Provider::Kerberos::TOK_ID_KRB_AP_REQ + + "\x6e\x82\x0b\xfd".b + end + + it 'reads the identifier from past the mechanism OID' do + expect(RubySMB::Gss::Provider::Kerberos.token_id(initial_context_token)) + .to eq(RubySMB::Gss::Provider::Kerberos::TOK_ID_KRB_AP_REQ) + end + + it 'handles a short form length' do + short = "\x60\x14".b + OpenSSL::ASN1::ObjectId.new('1.2.840.113554.1.2.2').to_der + + RubySMB::Gss::Provider::Kerberos::TOK_ID_KRB_AP_REP + "\x6f\x00".b + expect(RubySMB::Gss::Provider::Kerberos.token_id(short)) + .to eq(RubySMB::Gss::Provider::Kerberos::TOK_ID_KRB_AP_REP) + end + + it 'is nil for anything not shaped like an InitialContextToken' do + expect(RubySMB::Gss::Provider::Kerberos.token_id(nil)).to be_nil + expect(RubySMB::Gss::Provider::Kerberos.token_id('')).to be_nil + expect(RubySMB::Gss::Provider::Kerberos.token_id('short')).to be_nil + # a SEQUENCE rather than an InitialContextToken + expect(RubySMB::Gss::Provider::Kerberos.token_id("\x30\x82\x00\x05".b)).to be_nil + end + + it 'is nil when no mechanism OID follows' do + expect(RubySMB::Gss::Provider::Kerberos.token_id("\x60\x04\x02\x01\x05\x00".b)).to be_nil + end + end + + describe '#on_mech_token' do + it 'can be set with a block' do + provider.on_mech_token { |_token, _authenticator| :handled } + expect(provider.on_mech_token('token', nil)).to eq(:handled) + end + + it 'can be set through the constructor' do + configured = RubySMB::Gss::Provider::Kerberos.new { |_token, _authenticator| :handled } + expect(configured.on_mech_token('token', nil)).to eq(:handled) + end + + it 'is nil when no handler has been set' do + expect(provider.on_mech_token('token', nil)).to be_nil + end + end + + # referenced explicitly; described_class would resolve to the authenticator inside this group + describe RubySMB::Gss::Provider::Kerberos::Authenticator do + subject(:authenticator) { provider.new_authenticator(server_client) } + + describe '#process' do + context 'when building the advertisement' do + it 'offers the Kerberos mechanisms' do + expect(authenticator.process(nil).buffer).to eq(RubySMB::Gss.gss_neg_token_init(provider.mech_types)) + end + + it 'succeeds' do + expect(authenticator.process(nil).nt_status).to eq(WindowsError::NTStatus::STATUS_SUCCESS) + end + end + + context 'with a mechanism token' do + it 'passes the token to the handler' do + received = nil + provider.on_mech_token { |token, _authenticator| received = token; nil } + authenticator.process(neg_token_init(mech_token)) + expect(received).to eq(mech_token) + end + + it 'does not alter the token, so a forwarded ticket stays valid' do + received = nil + provider.on_mech_token { |token, _authenticator| received = token; nil } + authenticator.process(neg_token_init(mech_token)) + expect(received).to eq(mech_token) + expect(received[2..]).to eq(ap_req) + end + + it 'records the token on the authenticator' do + authenticator.process(neg_token_init(mech_token)) + expect(authenticator.mech_token).to eq(mech_token) + end + + it 'returns whatever the handler decides' do + expected = RubySMB::Gss::Provider::Result.new(nil, WindowsError::NTStatus::STATUS_SUCCESS) + provider.on_mech_token { |_token, _authenticator| expected } + expect(authenticator.process(neg_token_init(mech_token))).to be(expected) + end + + it 'refuses the attempt when no handler is set' do + # nothing here can validate a ticket, so the attempt must not silently succeed + result = authenticator.process(neg_token_init(mech_token)) + expect(result.nt_status).to eq(WindowsError::NTStatus::STATUS_LOGON_FAILURE) + end + + it 'accepts a token carried in a continuation' do + received = nil + provider.on_mech_token { |token, _authenticator| received = token; nil } + authenticator.process(RubySMB::Gss.gss_type3(mech_token)) + expect(received).to eq(mech_token) + end + end + + context 'with a malformed request' do + it 'returns nil rather than raising' do + expect(authenticator.process('not asn1 at all')).to be_nil + end + + it 'returns nil when there is no mechanism token' do + expect(authenticator.process(neg_token_init(nil))).to be_nil + end + end + end + + describe '#reset!' do + it 'forgets the recorded token' do + authenticator.process(neg_token_init(mech_token)) + expect(authenticator.mech_token).to_not be_nil + authenticator.reset! + expect(authenticator.mech_token).to be_nil + end + end + end + + describe 'alongside NTLM' do + let(:ntlm_provider) { RubySMB::Gss::Provider::NTLM.new.tap { |p| p.put_account('RubySMB', 'password') } } + let(:multi) { RubySMB::Gss::Provider::Multi.new([provider, ntlm_provider]) } + + it 'is offered ahead of NTLM' do + expect(multi.mech_types.map(&:oid)).to eq( + [ + RubySMB::Gss::OID_KERBEROS_5.oid, + RubySMB::Gss::OID_MICROSOFT_KERBEROS_5.oid, + RubySMB::Gss::OID_NTLMSSP.oid + ] + ) + end + + it 'receives the token when a client selects Kerberos' do + received = nil + provider.on_mech_token { |token, _authenticator| received = token; nil } + multi.new_authenticator(server_client).process(neg_token_init(mech_token)) + expect(received).to eq(mech_token) + end + + it 'is left alone when a client selects NTLM' do + received = nil + provider.on_mech_token { |token, _authenticator| received = token; nil } + type1 = Net::NTLM::Message::Type1.new.tap { |msg| msg.domain = 'WORKGROUP' } + result = multi.new_authenticator(server_client).process(RubySMB::Gss.gss_type1(type1.serialize)) + expect(received).to be_nil + expect(result.nt_status).to eq(WindowsError::NTStatus::STATUS_MORE_PROCESSING_REQUIRED) + end + end + + # Build a SPNEGO NegTokenInit selecting Kerberos and carrying the specified mechanism token. + def neg_token_init(token) + inner = [OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::Sequence.new([RubySMB::Gss::OID_KERBEROS_5])], 0, :CONTEXT_SPECIFIC)] + inner << OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::OctetString.new(token)], 2, :CONTEXT_SPECIFIC) unless token.nil? + + OpenSSL::ASN1::ASN1Data.new( + [ + RubySMB::Gss::OID_SPNEGO, + OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::Sequence.new(inner)], 0, :CONTEXT_SPECIFIC) + ], 0, :APPLICATION + ).to_der + end +end diff --git a/spec/lib/ruby_smb/gss/provider/multi_spec.rb b/spec/lib/ruby_smb/gss/provider/multi_spec.rb new file mode 100644 index 000000000..5b444d182 --- /dev/null +++ b/spec/lib/ruby_smb/gss/provider/multi_spec.rb @@ -0,0 +1,174 @@ +RSpec.describe RubySMB::Gss::Provider::Multi do + let(:username) { 'RubySMB' } + let(:domain) { 'WORKGROUP' } + let(:password) { 'password' } + let(:ntlm_provider) do + RubySMB::Gss::Provider::NTLM.new.tap { |provider| provider.put_account(username, password, domain: domain) } + end + let(:other_authenticator) { double('authenticator', process: nil, reset!: nil, session_key: nil) } + # a stand-in for any non-NTLM mechanism, so the routing can be exercised without a second real provider + let(:other_provider) do + authenticator = other_authenticator + Class.new(RubySMB::Gss::Provider::Base) do + define_method(:mech_types) do + [RubySMB::Gss::OID_KERBEROS_5, RubySMB::Gss::OID_MICROSOFT_KERBEROS_5] + end + + define_method(:new_authenticator) { |_server_client| authenticator } + end.new + end + let(:server_client) { double('server_client', logger: Logger.new(IO::NULL)) } + + # referenced explicitly rather than via described_class, which resolves to the authenticator inside the nested group + subject(:provider) { RubySMB::Gss::Provider::Multi.new([other_provider, ntlm_provider]) } + + describe '#initialize' do + it 'requires at least one provider' do + expect { RubySMB::Gss::Provider::Multi.new([]) }.to raise_error(ArgumentError) + expect { RubySMB::Gss::Provider::Multi.new(nil) }.to raise_error(ArgumentError) + end + end + + describe '#mech_types' do + it 'advertises every mechanism of every provider' do + expect(provider.mech_types.map(&:oid)).to eq( + [ + RubySMB::Gss::OID_KERBEROS_5.oid, + RubySMB::Gss::OID_MICROSOFT_KERBEROS_5.oid, + RubySMB::Gss::OID_NTLMSSP.oid + ] + ) + end + + it 'preserves the order the providers were given in' do + reversed = RubySMB::Gss::Provider::Multi.new([ntlm_provider, other_provider]) + expect(reversed.mech_types.first.oid).to eq(RubySMB::Gss::OID_NTLMSSP.oid) + end + + it 'advertises a mechanism supported by two providers only once' do + duplicated = RubySMB::Gss::Provider::Multi.new([ntlm_provider, RubySMB::Gss::Provider::NTLM.new]) + expect(duplicated.mech_types.length).to eq(1) + end + end + + describe '#provider_for' do + it 'finds the provider that handles the mechanism' do + expect(provider.provider_for(RubySMB::Gss::OID_KERBEROS_5)).to be(other_provider) + expect(provider.provider_for(RubySMB::Gss::OID_NTLMSSP)).to be(ntlm_provider) + end + + it 'is nil when no provider handles the mechanism' do + expect(provider.provider_for(RubySMB::Gss::OID_NEGOEX)).to be_nil + end + end + + describe RubySMB::Gss::Provider::Multi::Authenticator do + subject(:authenticator) { provider.new_authenticator(server_client) } + + describe '#process' do + context 'when building the advertisement' do + it 'offers all of the mechanisms' do + buffer = authenticator.process(nil).buffer + expect(buffer).to eq(RubySMB::Gss.gss_neg_token_init(provider.mech_types)) + end + + it 'matches the underlying provider when only one is held' do + single = described_class.new(RubySMB::Gss::Provider::Multi.new([ntlm_provider]), server_client) + expect(single.process(nil).buffer).to eq(ntlm_provider.new_authenticator(server_client).process(nil).buffer) + end + + it 'succeeds' do + expect(authenticator.process(nil).nt_status).to eq(WindowsError::NTStatus::STATUS_SUCCESS) + end + end + + context 'when the client selects a mechanism' do + it 'routes the token to the provider that handles it' do + expect(other_authenticator).to receive(:process) + authenticator.process(gss_init(RubySMB::Gss::OID_KERBEROS_5)) + end + + it 'routes an NTLM token to the NTLM provider' do + type1 = Net::NTLM::Message::Type1.new.tap { |msg| msg.domain = domain } + result = authenticator.process(RubySMB::Gss.gss_type1(type1.serialize)) + expect(result.nt_status).to eq(WindowsError::NTStatus::STATUS_MORE_PROCESSING_REQUIRED) + end + + it 'refuses a mechanism no provider handles' do + expect(authenticator.process(gss_init(RubySMB::Gss::OID_NEGOEX))).to be_nil + end + end + + context 'when the client continues an exchange' do + it 'refuses a continuation before a mechanism has been selected' do + # a NegTokenResp carries no mechanism OID, so there is nothing to route on + expect(authenticator.process(RubySMB::Gss.gss_type3('anything'))).to be_nil + end + end + + it 'returns nil for a malformed request' do + expect(authenticator.process('not asn1 at all')).to be_nil + end + end + + describe 'a complete NTLM exchange' do + it 'authenticates the same as the NTLM provider on its own' do + expect(complete_ntlm_exchange(authenticator)).to eq( + complete_ntlm_exchange(ntlm_provider.new_authenticator(server_client)) + ) + end + + it 'succeeds for a known account' do + status, identity = complete_ntlm_exchange(authenticator) + expect(status).to eq(WindowsError::NTStatus::STATUS_SUCCESS) + expect(identity).to eq("#{domain}\\#{username}") + end + + it 'exposes the session key of the mechanism that authenticated' do + complete_ntlm_exchange(authenticator) + expect(authenticator.session_key).to_not be_nil + end + end + + describe '#reset!' do + it 'forgets the selected mechanism' do + complete_ntlm_exchange(authenticator) + authenticator.reset! + expect(authenticator.session_key).to be_nil + # with no mechanism selected, a continuation token has nothing to route to + expect(authenticator.process(RubySMB::Gss.gss_type3('anything'))).to be_nil + end + end + end + + # Build a NegTokenInit that selects the specified mechanism, with an empty mechToken. + def gss_init(mech_type) + OpenSSL::ASN1::ASN1Data.new( + [ + RubySMB::Gss::OID_SPNEGO, + OpenSSL::ASN1::ASN1Data.new( + [ + OpenSSL::ASN1::Sequence.new( + [ + OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::Sequence.new([mech_type])], 0, :CONTEXT_SPECIFIC), + OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::OctetString.new('')], 2, :CONTEXT_SPECIFIC) + ] + ) + ], 0, :CONTEXT_SPECIFIC + ) + ], 0, :APPLICATION + ).to_der + end + + # Drive a full NTLM negotiation through the authenticator, returning the final status and identity. + def complete_ntlm_exchange(authenticator) + authenticator.process(nil) + type1 = Net::NTLM::Message::Type1.new.tap { |msg| msg.domain = domain } + challenge_result = authenticator.process(RubySMB::Gss.gss_type1(type1.serialize)) + raw_type2 = RubySMB::Gss.asn1dig(OpenSSL::ASN1.decode(challenge_result.buffer), 0, 2, 0).value + type2 = Net::NTLM::Message.parse(raw_type2) + type3 = type2.response({ user: username, password: password, domain: domain }, { ntlmv2: true }) + result = authenticator.process(RubySMB::Gss.gss_type3(type3.serialize)) + [result.nt_status, result.identity] + end +end