1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357
|
# frozen_string_literal: true
# This file is part of the ruby-dbus project
# Copyright (C) 2007 Arnaud Cornet and Paul van Tilburg
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License, version 2.1 as published by the Free Software Foundation.
# See the file "COPYING" for the exact licensing terms.
module DBus
# Exception raised when authentication fails somehow.
class AuthenticationFailed < StandardError
end
# The Authentication Protocol.
# https://dbus.freedesktop.org/doc/dbus-specification.html#auth-protocol
#
# @api private
module Authentication
# Base class of authentication mechanisms
class Mechanism
# @!method call(challenge)
# @abstract
# Replies to server *challenge*, or sends an initial response if the challenge is `nil`.
# @param challenge [String,nil]
# @return [Array(Symbol,String)] pair [action, response], where
# - [:MechContinue, response] caller should send "DATA response" and go to :WaitingForData
# - [:MechOk, response] caller should send "DATA response" and go to :WaitingForOk
# - [:MechError, message] caller should send "ERROR message" and go to :WaitingForData
# Uppercase mechanism name, as sent to the server
# @return [String]
def name
self.class.to_s.upcase.sub(/.*::/, "")
end
end
# Anonymous authentication class.
# https://dbus.freedesktop.org/doc/dbus-specification.html#auth-mechanisms-anonymous
class Anonymous < Mechanism
def call(_challenge)
[:MechOk, "Ruby DBus"]
end
end
# Class for 'external' type authentication.
# https://dbus.freedesktop.org/doc/dbus-specification.html#auth-mechanisms-external
class External < Mechanism
# Performs the authentication.
def call(_challenge)
[:MechOk, Process.uid.to_s]
end
end
# A variant of EXTERNAL that doesn't say our UID.
# Seen busctl do this and it worked across a container boundary.
class ExternalWithoutUid < External
def name
"EXTERNAL"
end
def call(_challenge)
[:MechContinue, nil]
end
end
# Implements the AUTH DBUS_COOKIE_SHA1 mechanism.
# https://dbus.freedesktop.org/doc/dbus-specification.html#auth-mechanisms-sha
class DBusCookieSHA1 < Mechanism
# returns the modules name
def name
"DBUS_COOKIE_SHA1"
end
# First we are called with nil and we reply with our username.
# Then we prove that we can read that user's cookie file.
def call(challenge)
if challenge.nil?
require "etc"
# number of retries we have for auth
@retries = 1
return [:MechContinue, Etc.getlogin]
end
require "digest/sha1"
# name of cookie file, id of cookie in file, servers random challenge
context, id, s_challenge = challenge.split(" ")
# Random client challenge
c_challenge = 1.upto(s_challenge.bytesize / 2).map { rand(255).to_s }.join
# Search cookie file for id
path = File.join(ENV["HOME"], ".dbus-keyrings", context)
DBus.logger.debug "path: #{path.inspect}"
File.foreach(path) do |line|
if line.start_with?(id)
# Right line of file, read cookie
cookie = line.split(" ")[2].chomp
DBus.logger.debug "cookie: #{cookie.inspect}"
# Concatenate and encrypt
to_encrypt = [s_challenge, c_challenge, cookie].join(":")
sha = Digest::SHA1.hexdigest(to_encrypt)
# Return response
response = [:MechOk, "#{c_challenge} #{sha}"]
return response
end
end
return if @retries <= 0
# a little rescue magic
puts "ERROR: Could not auth, will now exit."
puts "ERROR: Unable to locate cookie, retry in 1 second."
@retries -= 1
sleep 1
call(challenge)
end
end
# Declare client state transitions, for ease of code reading.
# It is just a pair.
NextState = Struct.new(:state, :command_words)
# Authenticates the connection before messages can be exchanged.
class Client
# @return [Boolean] have we negotiated Unix file descriptor passing
# NOTE: not implemented yet in upper layers
attr_reader :unix_fd
# @return [String]
attr_reader :address_uuid
# Create a new authentication client.
# @param mechs [Array<Mechanism,Class>,nil] custom list of auth Mechanism objects or classes
def initialize(socket, mechs = nil)
@unix_fd = false
@address_uuid = nil
@socket = socket
@state = nil
@auth_list = mechs || [
External,
DBusCookieSHA1,
ExternalWithoutUid,
Anonymous
]
end
# Start the authentication process.
# @return [void]
# @raise [AuthenticationFailed]
def authenticate
DBus.logger.debug "Authenticating"
send_nul_byte
use_next_mechanism
@state, command = next_state_via_mechanism.to_a
send(command)
loop do
DBus.logger.debug "auth STATE: #{@state}"
words = next_msg
@state, command = next_state(words).to_a
break if [:TerminatedOk, :TerminatedError].include? @state
send(command)
end
raise AuthenticationFailed, command.first if @state == :TerminatedError
send("BEGIN")
end
##########
private
##########
# The authentication protocol requires a nul byte
# that may carry credentials.
# @return [void]
def send_nul_byte
if Platform.freebsd?
@socket.sendmsg(0.chr, 0, nil, [:SOCKET, :SCM_CREDS, ""])
else
@socket.write(0.chr)
end
end
# encode plain to hex
# @param plain [String,nil]
# @return [String,nil]
def hex_encode(plain)
return nil if plain.nil?
plain.unpack1("H*")
end
# decode hex to plain
# @param encoded [String,nil]
# @return [String,nil]
def hex_decode(encoded)
return nil if encoded.nil?
[encoded].pack("H*")
end
# Send a string to the socket; good place for test mocks.
def write_line(str)
DBus.logger.debug "auth_write: #{str.inspect}"
@socket.write(str)
end
# Send *words* to the server as a single CRLF terminated string.
# @param words [Array<String>,String]
def send(words)
joined = Array(words).compact.join(" ")
write_line("#{joined}\r\n")
end
# Try authentication using the next mechanism.
# @raise [AuthenticationFailed] if there are no more left
# @return [void]
def use_next_mechanism
raise AuthenticationFailed, "Authentication mechanisms exhausted" if @auth_list.empty?
@mechanism = @auth_list.shift
@mechanism = @mechanism.new if @mechanism.is_a? Class
rescue AuthenticationFailed
# TODO: make this caller's responsibility
@socket.close
raise
end
# Read data (a buffer) from the bus until CR LF is encountered.
# Return the buffer without the CR LF characters.
# @return [Array<String>] received words
def next_msg
read_line.chomp.split(" ")
end
# Read a line from the socket; good place for test mocks.
# @return [String] CRLF (\r\n) terminated
def read_line
# TODO: probably can simply call @socket.readline
data = ""
crlf = "\r\n"
left = 1024 # 1024 byte, no idea if it's ever getting bigger
while left.positive?
buf = @socket.read(left > 1 ? 1 : left)
break if buf.nil?
left -= buf.bytesize
data += buf
break if data.include? crlf # crlf means line finished, the TCP socket keeps on listening, so we break
end
DBus.logger.debug "auth_read: #{data.inspect}"
data
end
# # Read data (a buffer) from the bus until CR LF is encountered.
# # Return the buffer without the CR LF characters.
# def next_msg
# @socket.readline.chomp.split(" ")
# end
# @param hex_challenge [String,nil] (nil when the server said "DATA\r\n")
# @param use_data [Boolean] say DATA instead of AUTH
# @return [NextState]
def next_state_via_mechanism(hex_challenge = nil, use_data: false)
challenge = hex_decode(hex_challenge)
action, response = @mechanism.call(challenge)
DBus.logger.debug "auth mechanism action: #{action.inspect}"
command = use_data ? ["DATA"] : ["AUTH", @mechanism.name]
case action
when :MechError
NextState.new(:WaitingForData, ["ERROR", response])
when :MechContinue
NextState.new(:WaitingForData, command + [hex_encode(response)])
when :MechOk
NextState.new(:WaitingForOk, command + [hex_encode(response)])
else
raise AuthenticationFailed, "internal error, unknown action #{action.inspect} " \
"from our mechanism #{@mechanism.inspect}"
end
end
# Try to reach the next state based on the current state.
# @param received_words [Array<String>]
# @return [NextState]
def next_state(received_words)
msg = received_words
case @state
when :WaitingForData
case msg[0]
when "DATA"
next_state_via_mechanism(msg[1], use_data: true)
when "REJECTED"
use_next_mechanism
next_state_via_mechanism
when "ERROR"
NextState.new(:WaitingForReject, ["CANCEL"])
when "OK"
@address_uuid = msg[1]
# NextState.new(:TerminatedOk, [])
NextState.new(:WaitingForAgreeUnixFD, ["NEGOTIATE_UNIX_FD"])
else
NextState.new(:WaitingForData, ["ERROR"])
end
when :WaitingForOk
case msg[0]
when "OK"
@address_uuid = msg[1]
# NextState.new(:TerminatedOk, [])
NextState.new(:WaitingForAgreeUnixFD, ["NEGOTIATE_UNIX_FD"])
when "REJECTED"
use_next_mechanism
next_state_via_mechanism
when "DATA", "ERROR"
NextState.new(:WaitingForReject, ["CANCEL"])
else
# we don't understand server's response but still wait for a successful auth completion
NextState.new(:WaitingForOk, ["ERROR"])
end
when :WaitingForReject
case msg[0]
when "REJECTED"
use_next_mechanism
next_state_via_mechanism
else
# TODO: spec says to close socket, clarify
NextState.new(:TerminatedError, ["Unknown server reply #{msg[0].inspect} when expecting REJECTED"])
end
when :WaitingForAgreeUnixFD
case msg[0]
when "AGREE_UNIX_FD"
@unix_fd = true
NextState.new(:TerminatedOk, [])
when "ERROR"
@unix_fd = false
NextState.new(:TerminatedOk, [])
else
# TODO: spec says to close socket, clarify
NextState.new(:TerminatedError, ["Unknown server reply #{msg[0].inspect} to NEGOTIATE_UNIX_FD"])
end
else
raise "Internal error: unhandled state #{@state.inspect}"
end
end
end
end
end
|