|
require 'openssl' |
|
require 'socket' |
|
require 'stringio' |
|
|
|
SECRET = 'radsec' # note: this is not radsec yet |
|
USERS = {'user'=>'password'} |
|
DEBUG = ARGV.include?('--debug') |
|
|
|
tls_connections = {} |
|
tls_context = OpenSSL::SSL::SSLContext.new |
|
unless File.exists?('./server.crt') |
|
raise "please run: openssl req -nodes -x509 -newkey rsa:4096 -sha256 -subj '/CN=localhost/' -days 99999 -keyout server.key -out server.crt" |
|
end |
|
tls_context.add_certificate( |
|
OpenSSL::X509::Certificate.new(File.read('./server.crt')), |
|
OpenSSL::PKey::RSA.new( File.read('./server.key')) |
|
) |
|
tls_context.min_version = OpenSSL::SSL::TLS1_2_VERSION |
|
tls_context.max_version = OpenSSL::SSL::TLS1_3_VERSION |
|
tls_context.ciphers = 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305' |
|
|
|
# hack because you got no patch yet :P |
|
unless OpenSSL::SSL::SSLSocket.instance_methods.include?(:export_keying_material) |
|
class OpenSSL::SSL::SSLSocket |
|
def export_keying_material(label, len, context=nil) |
|
raise 'install or wait for https://github.com/ruby/openssl/pull/530' |
|
end |
|
end |
|
end |
|
|
|
RADIUS_PACKET_CODE = { |
|
1=> 'Access-Request', |
|
2=> 'Access-Accept', |
|
3=> 'Access-Reject', |
|
11=> 'Access-Challenge', |
|
|
|
} |
|
RADIUS_PACKET_CODE_INVERT = RADIUS_PACKET_CODE.invert |
|
|
|
# https://www.iana.org/assignments/radius-types/radius-types.xhtml#radius-types-2 |
|
RADIUS_ATTRIBUTE_TYPE = { |
|
1=> 'User-Name', |
|
2=> 'User-Password', |
|
26=> 'Vendor-Specific', |
|
79=> 'EAP-Message', |
|
80=> 'Message-Authenticator', |
|
} |
|
RADIUS_ATTRIBUTE_TYPE_INVERT = RADIUS_ATTRIBUTE_TYPE.invert |
|
|
|
VENDOR_IDS = { |
|
311=>'Microsoft', |
|
} |
|
VENDOR_IDS_INVERT = VENDOR_IDS.invert |
|
|
|
VENDOR_SPECIFIC = { |
|
311=>{ |
|
16=>'MS-MPPE-Send-Key', |
|
17=>'MS-MPPE-Recv-Key', |
|
} |
|
} |
|
VENDOR_SPECIFIC_INVERT = VENDOR_SPECIFIC.transform_values(&:invert) |
|
|
|
# https://www.iana.org/assignments/eap-numbers/eap-numbers.xhtml#eap-numbers-1 |
|
EAP_PACKET_CODE = { |
|
1=>'Request', |
|
2=>'Response', |
|
3=>'Success', |
|
4=>'Failure', |
|
} |
|
EAP_PACKET_CODE_INVERT = EAP_PACKET_CODE.invert |
|
|
|
# https://www.iana.org/assignments/eap-numbers/eap-numbers.xhtml#eap-numbers-4 |
|
EAP_METHOD_TYPES = { |
|
1=>'Identity', |
|
21=>'EAP-TTLS', |
|
} |
|
EAP_METHOD_TYPES_INVERT = EAP_METHOD_TYPES.invert |
|
|
|
def eap_parse(pkt) |
|
# https://datatracker.ietf.org/doc/html/rfc2284#section-2.2 |
|
package_type = EAP_PACKET_CODE.fetch(pkt.read(1).unpack1('C')) rescue (raise 'unknown eap package type') |
|
identifier = pkt.read(1).unpack1('C') |
|
length = pkt.read(2).unpack1('S>') |
|
raise 'length not in 4 and 4096' unless (4..4096).include?(length) |
|
method = nil |
|
data = nil |
|
if %w(Request Response).include?(package_type) |
|
if length - 4 > 0 |
|
method = EAP_METHOD_TYPES.fetch(pkt.read(1).unpack1('C')) rescue (raise 'unknown eap method') |
|
if method == 'EAP-TTLS' |
|
data = {} |
|
flags = pkt.read(1).unpack1('C') |
|
data['Length included'] = flags & (2<<6) != 0 |
|
data['More Fragments'] = flags & (2<<5) != 0 |
|
data['Start'] = flags & (2<<4) != 0 |
|
data['Version'] = flags & 7 |
|
puts "version not 0" unless data['Version'] == 0 |
|
if data['Length included'] |
|
data['message length'] = pkt.read(4).unpack1('L>') |
|
end |
|
data['data'] = pkt.read() |
|
else |
|
data = pkt.read() |
|
end |
|
else |
|
raise 'hmm expected some eap method here' |
|
end |
|
end |
|
return {type: package_type, id: identifier, method: method, data: data} |
|
end |
|
|
|
def eap_response_single(type, id, method=nil, data=nil) |
|
return [EAP_PACKET_CODE_INVERT.fetch(type, type), id, 4].pack('CCS>').b if not method |
|
return [EAP_PACKET_CODE_INVERT.fetch(type, type), id, 5, EAP_METHOD_TYPES_INVERT.fetch(method, method)].pack('CCS>C').b if not data |
|
return [EAP_PACKET_CODE_INVERT.fetch(type, type), id, 5+data.size, EAP_METHOD_TYPES_INVERT.fetch(method, method), data].pack('CCS>Ca*').b |
|
end |
|
def eap_response(type, id, method=nil, data=nil, **fields) |
|
ret = eap_response_single(type, id, method, eap_ttls_data_encode(data, data&.b&.size)) |
|
return ret.each_char.each_slice(253).map(&:join) if ret.length > 253 |
|
return [ret] |
|
end |
|
|
|
def eap_ttls_data_encode(data=nil, message_len=nil, **fields) |
|
return nil if data.nil? || data.size == 0 |
|
fields.default = '0' |
|
fields['Length included'] = '1' unless message_len.nil? |
|
out = [fields.values_at('Length included', 'More Fragments', 'Start', 'r1','r2').join() + '000'].pack('B*') |
|
out += [message_len].pack('L>') if fields['Length included'] == '1' |
|
data.nil? ? out.b : (out + data).b |
|
end |
|
|
|
def eap_ttls_avp_parse(pkt) |
|
attrs = {} |
|
until pkt.eof? |
|
old_pos = pkt.pos |
|
if pkt.read().tr("\0", '') == '' |
|
break |
|
else |
|
pkt.pos = old_pos |
|
end |
|
code = pkt.read(4).unpack1('L>') |
|
code = RADIUS_ATTRIBUTE_TYPE.fetch(code) if code <= 256 |
|
flags = pkt.read(1).unpack1('C') |
|
raise 'Unknown attribute' if (flags & 64) != 0 && code.nil? |
|
length = ("\x00" + pkt.read(3)).unpack1('L>') - 8 |
|
if (flags & 128) != 0 |
|
vendor = pkt.read(4).unpack1('L>') |
|
length -= 4 |
|
end |
|
data = pkt.read(length) |
|
data = data.b.sub(/\0+$/, '') if code == 'User-Password' |
|
attrs[code] ||= [] |
|
attrs[code] << data |
|
end |
|
attrs |
|
end |
|
|
|
def radius_parse(pkt) |
|
package_type = RADIUS_PACKET_CODE.fetch(pkt.read(1).unpack1('C')) rescue (raise 'unknown radius package type') |
|
identifier = pkt.read(1).unpack1('C') |
|
length = pkt.read(2).unpack1('S>') |
|
raise 'length not in 20 and 4096' unless (20..4096).include?(length) |
|
authenticator = pkt.read(16) |
|
attrs = StringIO.new(pkt.read(length - 20)) rescue StringIO.new('') |
|
attrs.binmode |
|
attributes = {} |
|
while not attrs.eof? |
|
begin |
|
attr_type = RADIUS_ATTRIBUTE_TYPE.fetch(attrs.read(1).unpack1('C')) |
|
attr_length = attrs.read(1).unpack1('C') |
|
attr_value = attrs.read(attr_length-2) |
|
attributes[attr_type] ||= [] |
|
attributes[attr_type] << |
|
if attr_type == 'EAP-Message' |
|
eap_parse(StringIO.new(attr_value).binmode) |
|
elsif attr_type == 'Vendor-Specific' |
|
radius_response_vendor_value_parse(StringIO.new(attr_value).binmode) |
|
else |
|
attr_value |
|
end |
|
rescue KeyError |
|
puts 'ignoring attribute' if DEBUG |
|
attrs.read(attrs.read(1).unpack1('C')-2) # throw away the data |
|
end |
|
end |
|
return {type: package_type, id: identifier, auth: authenticator, attributes: attributes} |
|
end |
|
|
|
def radius_message_auth(secret, type, id, length, authenticator, attrs) |
|
OpenSSL::HMAC.digest("MD5", secret, [ |
|
type, |
|
id, |
|
length, |
|
authenticator, |
|
attrs + [RADIUS_ATTRIBUTE_TYPE_INVERT['Message-Authenticator'], 18,''].pack('CCa16') |
|
].pack('CCS>a16A*')) |
|
end |
|
|
|
def radius_response_vendor_value(vendor, attributes) |
|
[id = VENDOR_IDS_INVERT.fetch(vendor, vendor), attributes.map do |t,vals| |
|
vals.map{|v|[VENDOR_SPECIFIC_INVERT.fetch(id).fetch(t,t), v.length+2, v].pack('CCA*')}.join('') |
|
end.join('')].pack('L>A*') |
|
end |
|
|
|
def radius_response_vendor_value_parse(buffer) |
|
package = {} |
|
id_int = buffer.read(4).unpack1('L>') |
|
package[:id] = VENDOR_IDS.fetch(id_int, nil) |
|
if package[:id] |
|
package[:type] = VENDOR_SPECIFIC[id_int].fetch(buffer.read(1).unpack1('C')) |
|
package[:data] = buffer.read(buffer.read(1).unpack1('C') - 2) |
|
end |
|
package |
|
end |
|
|
|
def radius_response(secret, code, request, attributes={}) |
|
code = RADIUS_PACKET_CODE_INVERT.fetch(code, code) |
|
attrs = attributes.map{|t,vals| vals.map{|v| raise "toolong #{v.length}" if v.length >=254;[RADIUS_ATTRIBUTE_TYPE_INVERT.fetch(t,t), v.length+2, v].pack('CCA*')}.join('')}.join('').b |
|
length = 20 + attrs.length |
|
raise 'too long' if length > 4096 |
|
if attributes['EAP-Message'] |
|
# see RFC 3579 |
|
length += 18 |
|
mauth = radius_message_auth(secret, code, request[:id], length, request[:auth], attrs) |
|
attrs << [RADIUS_ATTRIBUTE_TYPE_INVERT['Message-Authenticator'], 18, mauth].pack('CCa16') |
|
end |
|
authenticator = OpenSSL::Digest::MD5.digest( |
|
[code, request[:id], length, request[:auth], attrs, secret].pack('CCS>a16A*A*') |
|
) |
|
[code, request[:id], length, authenticator, attrs].pack('CCS>a16A*') |
|
end |
|
|
|
# https://datatracker.ietf.org/doc/html/rfc2548#section-2.4.3 |
|
# Note: decrypt is using a ciphertext as plaintext, its a md5 stream cipher |
|
def microsoft_md5_cipher(secret, authenticator_plus_salt, plaintext) |
|
require 'openssl' |
|
b = OpenSSL::Digest::MD5.digest(secret + authenticator_plus_salt) |
|
plaintext.b.split('').each_slice(16).map do |plain| |
|
c = plain.join().ljust(16, "\0").each_byte.zip(b.each_byte).map{|x,y|(x^y).chr}.join('') |
|
b = OpenSSL::Digest::MD5.digest(secret + c).b |
|
c |
|
end.join() |
|
end |
|
|
|
a = "\xFA\xD7\xC0r\xABWbE\x8F\xCE\x8Fo\x9F\xF2\xCD\x93\x80a".b |
|
b = "|\x92\xBC\x9D\xDB[\x12\x9C^Y_\x16\xA8s(U".b |
|
c = "\x1D\xE0\x17\xC7\xC6\xDA\xD7[~\x8B\x03c\xF3\xBC\x83\x8F".b |
|
raise 'AH! (microsoft_md5_cipher broken)' unless microsoft_md5_cipher('s3cr3t', a, b) == c |
|
raise 'AH! (microsoft_md5_cipher broken)' unless microsoft_md5_cipher('s3cr3t', a, microsoft_md5_cipher('s3cr3t', a, b)) == b |
|
|
|
Socket.udp_server_loop(1812) do |msg, client| |
|
request = radius_parse(StringIO.new(msg).binmode) |
|
if DEBUG |
|
puts '------------------------------------------------ New RADIUS Packet' |
|
p request |
|
end |
|
case request |
|
in {type: 'Access-Request'} |
|
if eap = request[:attributes]['EAP-Message'] |
|
eap = eap.first |
|
if eap[:method] == 'EAP-TTLS' |
|
unless tls_connections['a'] |
|
puts "! starting TLS inside of EAP connection" |
|
tunnel_socket, plain_socket = UNIXSocket.pair |
|
tls_socket = OpenSSL::SSL::SSLSocket.new(tunnel_socket, tls_context) |
|
tls_socket.sync_close = true |
|
tls_connections['a'] = [tls_socket, plain_socket, [], nil] |
|
if eap[:data] |
|
plain_socket.send(eap[:data]['data'], 0) |
|
thing = (tls_socket.accept_nonblock rescue '') |
|
response = plain_socket.recv(99999) |
|
tls_connections['a'][2] = response.b.each_char.each_slice(1450).map(&:join) |
|
client.reply(radius_response(SECRET, 'Access-Challenge', request, {'EAP-Message'=>eap_response('Request', eap[:id]+1, 'EAP-TTLS', tls_connections['a'][2].shift)})) |
|
end |
|
else |
|
tls_socket, plain_socket, messages, socket = tls_connections['a'] |
|
if socket.nil? |
|
socket = (tls_socket.accept_nonblock rescue nil) |
|
tls_connections['a'][3] = socket |
|
end |
|
plain_socket.send(eap[:data]['data'], 0) |
|
response = plain_socket.recv_nonblock(99999) rescue '' |
|
if response != '' |
|
tls_connections['a'][2] = response.b.each_char.each_slice(1450).map(&:join) |
|
messages = [true] |
|
end |
|
if messages.any? |
|
puts '! send TLS data until we finished sending a TLS message' |
|
client.reply(radius_response(SECRET, 'Access-Challenge', request, {'EAP-Message'=>eap_response('Request', eap[:id]+1, 'EAP-TTLS', tls_connections['a'][2].shift)})) |
|
next |
|
end |
|
tls_query = socket.read_nonblock(99999) rescue nil |
|
if tls_query |
|
puts '--------------------- New TTLS encapsulated EAP Packet' |
|
avp = eap_ttls_avp_parse(StringIO.new(tls_query).binmode) |
|
p(avp) if DEBUG |
|
if avp['User-Password'] && avp['User-Name'] # PAP |
|
puts '! doing EAP-PAP authentication path' |
|
# vvvvvvvvvvvvvvvv worst possible implementation vvvvvvvvvvvvvvvv |
|
ok = USERS[avp['User-Name'].first] == avp['User-Password'].first |
|
# ^^^^^^^^^^^^^^^^ worst possible implementation ^^^^^^^^^^^^^^^^ |
|
if ok |
|
puts '! sent ok' |
|
# https://datatracker.ietf.org/doc/html/rfc5705 |
|
msk = socket.export_keying_material("ttls keying material", 64).b |
|
keyshare = [ |
|
[msk[...32], (2**7).chr + "a"], |
|
[msk[32...], (2**7).chr + "b"] |
|
].map do |msk, s| |
|
radius_response_vendor_value('Microsoft', {"MS-MPPE-Recv-Key"=>[s + microsoft_md5_cipher(SECRET, request[:auth]+s, [32].pack('C')+msk)]}) |
|
end |
|
client.reply(r = radius_response(SECRET, 'Access-Accept', request, {'EAP-Message'=>eap_response('Success', eap[:id]+1, 'EAP-TTLS'), 'Vendor-Specific'=>keyshare})) |
|
p(radius_parse(StringIO.new(r).binmode)) if DEBUG |
|
else |
|
puts '! sent nok' |
|
client.reply(radius_response(SECRET, 'Access-Reject', request, {'EAP-Message'=>eap_response('Failure', eap[:id]+1, 'EAP-TTLS')})) |
|
end |
|
puts '! killing connection, done' |
|
tls_connections.delete('a') |
|
elsif avp['EAP-Message'] # EAP goes even deeper here |
|
puts '! not implemented for now' |
|
client.reply(radius_response(SECRET, 'Access-Reject', request)) |
|
else |
|
puts '! unknown auth here!' |
|
client.reply(radius_response(SECRET, 'Access-Reject', request)) |
|
end |
|
next # ignore the stuff below |
|
end |
|
response = plain_socket.recv_nonblock(99999) rescue '' |
|
raise 'TLS response should be empty here, we expect data' if response != '' |
|
puts '! sending empty TLS data to get responses' |
|
client.reply(radius_response(SECRET, 'Access-Challenge', request, {'EAP-Message'=>eap_response('Request', eap[:id]+1, 'EAP-TTLS', '')})) |
|
end |
|
else |
|
puts "! not TTLS, trying to get the client to start TTLS" |
|
client.reply(radius_response(SECRET, 'Access-Challenge', request, {'EAP-Message'=>[eap_response_single('Request', eap[:id]+1, 'EAP-TTLS', eap_ttls_data_encode('Start'=>'1'))]})) |
|
end |
|
else |
|
puts "! rejected client" |
|
client.reply(radius_response(SECRET, 'Access-Reject', request)) |
|
end |
|
else |
|
puts "! did not got an Access-Request, not answering" |
|
end |
|
end |