From ebaa58968298df5d71cc63106f521e328aea4dcb Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:17:52 +0530 Subject: [PATCH 1/4] Let a GSS provider declare the mechanisms it supports The SPNEGO NegTokenInit a server sends to advertise its authentication mechanisms was built inside the NTLM authenticator, with a mechTypes list hardcoded to a single OID_NTLMSSP. A comment there noted the limitation: "this is only NTLMSSP (as opposed to SPNEGO + NTLMSSP)". Because the token was owned by the NTLM provider, no other mechanism had a way to contribute to the advertisement, so a server could never offer a client anything but NTLM. Move the NegTokenInit construction to Gss.gss_neg_token_init, which takes the mechTypes to advertise, and add Provider::Base#mech_types so a provider declares what it handles. NTLM declares OID_NTLMSSP, so the token it emits is byte identical to the one it built before. Also define the Kerberos v5 mechanism OIDs, both the RFC 4121 OID and the legacy Microsoft variant, since clients may offer or select either. No behaviour change: this only moves ownership of the mechanism list from the NTLM provider to the providers themselves. --- lib/ruby_smb/gss.rb | 37 +++++++++++++++++++++++++++++++ lib/ruby_smb/gss/provider.rb | 20 +++++++++++++++++ lib/ruby_smb/gss/provider/ntlm.rb | 25 +++++---------------- 3 files changed, 62 insertions(+), 20 deletions(-) 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..63bc05188 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. # 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 From 909611c9a92c497ef5166dc58da24faa2e383642 Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:29:24 +0530 Subject: [PATCH 2/4] Allow a server to offer more than one GSS mechanism A server held exactly one GSS provider, so it could only ever offer a client a single authentication mechanism. SPNEGO exists to let the two sides agree on a mechanism, but with one on offer there is nothing to negotiate. Add Provider::Multi, which holds an ordered list of providers, advertises the mechanisms of all of them, and routes each request to whichever one understands the mechanism the client selected. A NegTokenInit names the mechanism, so that is where the routing decision is made; a NegTokenResp carries no mechanism OID and is treated as a continuation of the exchange already under way. Routing happens in the authenticator rather than at the call sites, so it covers SMB1 and SMB2/3 alike: every request already funnels through ServerClient#process_gss. Sub-authenticators are built lazily, so a mechanism that is advertised but never selected is never instantiated, and the session key of whichever mechanism actually authenticated is exposed to the server for signing. Wrapping a single provider produces a byte identical advertisement and an identical authentication result, so existing servers are unaffected. --- lib/ruby_smb/gss/provider.rb | 1 + lib/ruby_smb/gss/provider/multi.rb | 129 ++++++++++++++ spec/lib/ruby_smb/gss/provider/multi_spec.rb | 174 +++++++++++++++++++ 3 files changed, 304 insertions(+) create mode 100644 lib/ruby_smb/gss/provider/multi.rb create mode 100644 spec/lib/ruby_smb/gss/provider/multi_spec.rb diff --git a/lib/ruby_smb/gss/provider.rb b/lib/ruby_smb/gss/provider.rb index 63bc05188..0258c6912 100644 --- a/lib/ruby_smb/gss/provider.rb +++ b/lib/ruby_smb/gss/provider.rb @@ -62,3 +62,4 @@ def supports_mech_type?(mech_type) require 'ruby_smb/gss/provider/authenticator' require 'ruby_smb/gss/provider/ntlm' +require 'ruby_smb/gss/provider/multi' 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/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 From a0601262ca3b14d810048f91eb0a2d1677ab4e6d Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:35:54 +0530 Subject: [PATCH 3/4] Add a Kerberos GSS provider that surfaces the mechanism token 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. Provider::Kerberos therefore does not try: it advertises the Kerberos mechanisms, and hands the mechanism token to a handler that decides how to reply. That is enough for a server to observe or forward Kerberos authentication, and it keeps Kerberos message parsing out of this library, so no new dependency is introduced and the token is never altered in transit. A handler receives the bytes exactly as the client sent them, which matters for anything that forwards the ticket elsewhere. Both the RFC 4121 mechanism OID and the legacy Microsoft variant are advertised, since clients may select either, and the RFC 4121 token identifiers are exposed so a handler can tell an AP-REQ from an AP-REP or a KRB-ERROR without decoding the payload. With no handler set the attempt is refused rather than silently accepted, since nothing here can validate a ticket. Accepting Kerberos properly, by decrypting the ticket with a service key and validating the PAC, is a separate concern and is not implemented here. --- lib/ruby_smb/gss/provider.rb | 1 + lib/ruby_smb/gss/provider/kerberos.rb | 121 +++++++++++++ .../ruby_smb/gss/provider/kerberos_spec.rb | 162 ++++++++++++++++++ 3 files changed, 284 insertions(+) create mode 100644 lib/ruby_smb/gss/provider/kerberos.rb create mode 100644 spec/lib/ruby_smb/gss/provider/kerberos_spec.rb diff --git a/lib/ruby_smb/gss/provider.rb b/lib/ruby_smb/gss/provider.rb index 0258c6912..3f81d9374 100644 --- a/lib/ruby_smb/gss/provider.rb +++ b/lib/ruby_smb/gss/provider.rb @@ -62,4 +62,5 @@ def supports_mech_type?(mech_type) 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..8860085fa --- /dev/null +++ b/lib/ruby_smb/gss/provider/kerberos.rb @@ -0,0 +1,121 @@ +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. + # + # @example Capture the token a client sends + # provider = RubySMB::Gss::Provider::Kerberos.new + # provider.on_mech_token do |token, authenticator| + # # token is the opaque GSS mechanism token, starting with its two byte token id + # RubySMB::Gss::Provider::Result.new(nil, WindowsError::NTStatus::STATUS_LOGON_FAILURE) + # end + # + class Kerberos < Base + # The GSS token identifiers that may prefix 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 + + # @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/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..a4779f1ad --- /dev/null +++ b/spec/lib/ruby_smb/gss/provider/kerberos_spec.rb @@ -0,0 +1,162 @@ +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 '#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 From f65afd34feab3c5e41971e823dac5f4cffd4f559 Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:15:06 +0530 Subject: [PATCH 4/4] Describe the Kerberos mechanism token accurately Lab testing against a Windows domain controller showed the documentation here was wrong about the shape of the token a client sends. The mechanism token 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 So the token id follows the OID rather than starting the token, which is what the previous comment implied, and the framing around it is not valid ASN.1, so OpenSSL::ASN1.decode cannot read it. Correct the documentation and add Kerberos.token_id, which locates the identifier by walking the lengths, so a handler can tell an AP-REQ from an AP-REP or a KRB-ERROR without decoding the payload or guessing at offsets. The provider itself was already handing up the token unaltered, which is what matters for anything forwarding it; only the description of it was wrong. --- lib/ruby_smb/gss/provider/kerberos.rb | 35 +++++++++++++++++-- .../ruby_smb/gss/provider/kerberos_spec.rb | 35 +++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/lib/ruby_smb/gss/provider/kerberos.rb b/lib/ruby_smb/gss/provider/kerberos.rb index 8860085fa..8c8866173 100644 --- a/lib/ruby_smb/gss/provider/kerberos.rb +++ b/lib/ruby_smb/gss/provider/kerberos.rb @@ -13,20 +13,51 @@ module Provider # 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| - # # token is the opaque GSS mechanism token, starting with its two byte token id + # 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 prefix a Kerberos mechanism token, per RFC 4121 section 4.1. They are + # 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 diff --git a/spec/lib/ruby_smb/gss/provider/kerberos_spec.rb b/spec/lib/ruby_smb/gss/provider/kerberos_spec.rb index a4779f1ad..4dfdd6307 100644 --- a/spec/lib/ruby_smb/gss/provider/kerberos_spec.rb +++ b/spec/lib/ruby_smb/gss/provider/kerberos_spec.rb @@ -23,6 +23,41 @@ 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 }