Skip to content
Open
23 changes: 23 additions & 0 deletions lib/ruby_smb/gss.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<OpenSSL::ASN1::ObjectId>] 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([
Expand Down
22 changes: 22 additions & 0 deletions lib/ruby_smb/gss/provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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<OpenSSL::ASN1::ObjectId>]
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.
#
Expand All @@ -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'
155 changes: 155 additions & 0 deletions lib/ruby_smb/gss/provider/kerberos.rb
Original file line number Diff line number Diff line change
@@ -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
129 changes: 129 additions & 0 deletions lib/ruby_smb/gss/provider/multi.rb
Original file line number Diff line number Diff line change
@@ -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<Provider::Base>] 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<Provider::Base>] 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<OpenSSL::ASN1::ObjectId>]
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
Loading
Loading