diff --git a/lib/ruby_smb/gss.rb b/lib/ruby_smb/gss.rb index 3dcbf4782..151f8c650 100644 --- a/lib/ruby_smb/gss.rb +++ b/lib/ruby_smb/gss.rb @@ -2,11 +2,18 @@ module RubySMB # module containing methods required for using the [GSS-API](http://www.rfc-editor.org/rfc/rfc2743.txt) # for Secure Protected Negotiation(SPNEGO) in SMB Authentication. module Gss + require 'ruby_smb/gss/spnego_neg_token_init' + require 'ruby_smb/gss/spnego_neg_token_targ' require 'ruby_smb/gss/provider' 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 +53,22 @@ 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? + + SpnegoNegTokenInit.build(mech_types) + 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..fd6e3b2d9 --- /dev/null +++ b/lib/ruby_smb/gss/provider/kerberos.rb @@ -0,0 +1,155 @@ +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| + # if RubySMB::Gss::Provider::Kerberos.token_id(token) == RubySMB::Gss::Provider::Kerberos::TOK_ID_KRB_AP_REQ + # # forward or record the AP-REQ, then decide how to reply + # end + # # a handler must return a Result; there is no service key here to validate the ticket, so refuse it + # 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 + + token = extract_mech_token(request_buffer) + 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) + # a handler must return a Result: the session setup path calls nt_status on whatever comes back, so + # anything else (a missing handler, or a handler that returns e.g. a boolean) is refused here rather + # than handed on to crash the caller + return result if result.is_a?(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 [String] request_buffer the SPNEGO token as received + # @return [String, nil] + def extract_mech_token(request_buffer) + # the identifier octet tells the two SPNEGO tokens apart: an InitialContextToken carrying a NegTokenInit is + # tagged [APPLICATION 0], a NegTokenResp continuing an exchange is tagged [CONTEXT 1] + case request_buffer.b.getbyte(0) + when 0x60 + SpnegoNegTokenInit.parse(request_buffer).mech_token + when 0xa1 + SpnegoNegTokenTarg.parse(request_buffer).response_token + end + rescue RASN1::ASN1Error => e + logger.error("Failed to parse the SPNEGO token (#{e.message})") + nil + 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/lib/ruby_smb/gss/spnego_neg_token_init.rb b/lib/ruby_smb/gss/spnego_neg_token_init.rb new file mode 100644 index 000000000..3e6ae88d0 --- /dev/null +++ b/lib/ruby_smb/gss/spnego_neg_token_init.rb @@ -0,0 +1,81 @@ +require 'rasn1' + +module RubySMB + module Gss + # The SPNEGO negotiation token an initiator sends first, and that a server sends to advertise the mechanisms it + # supports. Modelled with RASN1 so the fields can be read and built by name rather than by walking a decoded + # structure by hand. + # + # https://datatracker.ietf.org/doc/html/rfc4178#section-4.2.1 + class MechType < RASN1::Types::ObjectId + end + + class MechTypeList < RASN1::Model + sequence_of(:mech_type, MechType) + end + + class ContextFlags < RASN1::Types::BitString + def initialize(options = {}) + options[:bit_length] = 32 + super + end + end + + # RASN1 does not define a GeneralString type, and a SPNEGO negHints carries its hintName as one, so it is defined + # here as an octet string tagged UNIVERSAL 27. + class GeneralString < RASN1::Types::OctetString + ID = 27 + + def self.type + 'GeneralString' + end + end + + # NegHints, the optional field Microsoft servers place at [3] of a NegTokenInit2 in lieu of a mechListMIC. Windows + # servers send a fixed placeholder hintName, so a client that expects the field still finds one. + # + # https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-spng/8e71cf53-e867-4b79-9df6-cc9edb7f8829 + class NegHints < RASN1::Model + define_type_accel('general_string', GeneralString) + + sequence :neg_hints, + content: [general_string(:hint_name, explicit: 0, class: :context, constructed: true, optional: true), + octet_string(:hint_address, explicit: 1, class: :context, constructed: true, optional: true)] + end + + class NegTokenInit < RASN1::Model + sequence :neg_token_init, explicit: 0, class: :context, constructed: true, + content: [wrapper(model(:mech_type_list, MechTypeList), explicit: 0, constructed: true), + wrapper(model(:context_flags, ContextFlags), explicit: 1, constructed: true, optional: true), + octet_string(:mech_token, explicit: 2, constructed: true, optional: true), + wrapper(model(:neg_hints, NegHints), explicit: 3, constructed: true, optional: true)] + end + + class SpnegoNegTokenInit < RASN1::Model + # The placeholder hintName a Windows server sends, reproduced so the advertisement matches what a client expects. + NEG_HINTS_NAME = 'not_defined_in_RFC4178@please_ignore'.freeze + + sequence :gssapi, implicit: 0, class: :application, constructed: true, + content: [objectid(:oid), + model(:neg_token_init, NegTokenInit)] + + # Build the NegTokenInit a server sends to advertise the mechanisms it supports, including the Microsoft negHints + # placeholder so the token is shaped as a Windows server's is. + # + # @param [Array] mech_types the mechanisms to advertise, in preference order. + # @return [String] the DER encoded token. + def self.build(mech_types) + token = new + token[:gssapi][:oid].value = Gss::OID_SPNEGO.oid + token[:gssapi][:neg_token_init][:mech_type_list][:mech_type] = mech_types.map { |mech| MechType.new(value: mech.oid) } + token[:gssapi][:neg_token_init][:neg_hints][:hint_name] = NEG_HINTS_NAME + token.to_der + end + + # @return [String, nil] the mechanism token the initiator carried, or nil if it carried none. + def mech_token + self[:gssapi][:neg_token_init][:mech_token].value + end + end + end +end diff --git a/lib/ruby_smb/gss/spnego_neg_token_targ.rb b/lib/ruby_smb/gss/spnego_neg_token_targ.rb new file mode 100644 index 000000000..f40d82706 --- /dev/null +++ b/lib/ruby_smb/gss/spnego_neg_token_targ.rb @@ -0,0 +1,27 @@ +require 'rasn1' + +module RubySMB + module Gss + # The SPNEGO negotiation token exchanged after the first, carrying a continuation of the selected mechanism. + # A client sends one to continue an exchange, so it is where a mechanism token arrives on any leg past the first. + # + # https://www.rfc-editor.org/rfc/rfc2478 + class SpnegoNegTokenTarg < RASN1::Model + NEG_RESULTS = { 'accept-completed' => 0, + 'accept-incomplete' => 1, + 'reject' => 2, + 'request-mic' => 3 }.freeze + + sequence :token, explicit: 1, class: :context, constructed: true, + content: [enumerated(:neg_result, enum: NEG_RESULTS, explicit: 0, class: :context, constructed: true, optional: true), + objectid(:supported_mech, explicit: 1, class: :context, constructed: true, optional: true), + octet_string(:response_token, explicit: 2, class: :context, constructed: true, optional: true), + octet_string(:mech_list_mic, explicit: 3, class: :context, constructed: true, optional: true)] + + # @return [String, nil] the mechanism token the continuation carried, or nil if it carried none. + def response_token + self[:response_token].value + end + end + end +end diff --git a/ruby_smb.gemspec b/ruby_smb.gemspec index 3a990f075..8a4078cf2 100644 --- a/ruby_smb.gemspec +++ b/ruby_smb.gemspec @@ -43,6 +43,9 @@ Gem::Specification.new do |spec| spec.add_runtime_dependency 'rubyntlm', '>= 0.6.5' spec.add_runtime_dependency 'windows_error', '>= 0.1.4' spec.add_runtime_dependency 'bindata', '2.4.15' + # 0.12 introduced the model `wrapper` DSL the SPNEGO types rely on; the upper Ruby versions resolve to a + # newer rasn1, while Ruby 2.7 caps at 0.13.1 (0.14+ needs Ruby 3.0), and both are known good. + spec.add_runtime_dependency 'rasn1', '>= 0.12' spec.add_runtime_dependency 'openssl-ccm' spec.add_runtime_dependency 'openssl-cmac' end 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..87ee5351a --- /dev/null +++ b/spec/lib/ruby_smb/gss/provider/kerberos_spec.rb @@ -0,0 +1,206 @@ +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 'refuses the attempt when the handler returns something that is not a Result' do + # the session setup path calls nt_status on the result, so a non-Result (e.g. a boolean from a naive + # handler) must be refused here rather than handed on to crash the caller + provider.on_mech_token { |_token, _authenticator| true } + result = authenticator.process(neg_token_init(mech_token)) + expect(result).to be_a(RubySMB::Gss::Provider::Result) + 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 diff --git a/spec/lib/ruby_smb/gss/spnego_spec.rb b/spec/lib/ruby_smb/gss/spnego_spec.rb new file mode 100644 index 000000000..3cb808bdd --- /dev/null +++ b/spec/lib/ruby_smb/gss/spnego_spec.rb @@ -0,0 +1,80 @@ +RSpec.describe 'SPNEGO negotiation tokens' do + let(:mech_types) do + [RubySMB::Gss::OID_KERBEROS_5, RubySMB::Gss::OID_MICROSOFT_KERBEROS_5, RubySMB::Gss::OID_NTLMSSP] + end + + describe RubySMB::Gss::SpnegoNegTokenInit do + describe '.build' do + subject(:token) { described_class.build(mech_types) } + + # the exact bytes a server advertised before this was modelled with RASN1, kept so the wire format does not + # drift: the SPNEGO OID, the three mechanisms, and the Microsoft negHints placeholder + let(:legacy_der) do + [ + '605e06062b0601050502a0543052a024302206092a864886f71201020206092a864882' \ + 'f712010202060a2b06010401823702020aa32a3028a0261b246e6f745f646566696e65' \ + '645f696e5f5246433431373840706c656173655f69676e6f7265' + ].pack('H*') + end + + it 'is byte-identical to the token the hand-rolled builder produced' do + expect(token).to eq(legacy_der) + end + + # the exact NTLM-only advertisement a default server built before this change, when the NTLM provider + # hardcoded a single OID_NTLMSSP. existing servers still emit this, so lock it against a wire regression. + it 'is byte-identical to the NTLM-only advertisement a default server built before this change' do + legacy_ntlm_der = [ + '604806062b0601050502a03e303ca00e300c060a2b06010401823702020aa32a3028' \ + 'a0261b246e6f745f646566696e65645f696e5f5246433431373840706c656173655f' \ + '69676e6f7265' + ].pack('H*') + expect(described_class.build([RubySMB::Gss::OID_NTLMSSP])).to eq(legacy_ntlm_der) + end + + it 'advertises the mechanisms in order' do + decoded = OpenSSL::ASN1.decode(token) + mech_list = decoded.value[1].value[0].value[0].value[0].value + expect(mech_list.map(&:oid)).to eq(mech_types.map(&:oid)) + end + + it 'carries the Microsoft negHints placeholder' do + decoded = OpenSSL::ASN1.decode(token) + hint = decoded.value[1].value[0].value[1].value[0].value[0].value[0].value + expect(hint).to eq(RubySMB::Gss::SpnegoNegTokenInit::NEG_HINTS_NAME) + end + end + + describe '.parse' do + # a SPNEGO NegTokenInit selecting Kerberos and carrying the given 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 + + it 'reads the mechanism token' do + expect(described_class.parse(neg_token_init('a mechanism token')).mech_token).to eq('a mechanism token') + end + + it 'is nil when the token carries no mechanism token' do + expect(described_class.parse(neg_token_init(nil)).mech_token).to be_nil + end + end + end + + describe RubySMB::Gss::SpnegoNegTokenTarg do + describe '.parse' do + it 'reads the response token from a continuation' do + targ = described_class.parse(RubySMB::Gss.gss_type3('a continuation token')) + expect(targ.response_token).to eq('a continuation token') + end + end + end +end