Module: Legion::Extensions::Node::ControlAuth

Defined in:
lib/legion/extensions/node/control_auth.rb

Defined Under Namespace

Classes: UnauthorizedControlMessage

Constant Summary collapse

AUTH_MODES =
%w[auto required disabled].freeze

Class Method Summary collapse

Class Method Details

.auth_disabled?Boolean

Returns:

  • (Boolean)


80
81
82
# File 'lib/legion/extensions/node/control_auth.rb', line 80

def auth_disabled?
  auth_mode == 'disabled'
end

.auth_modeObject



66
67
68
69
70
71
72
73
74
# File 'lib/legion/extensions/node/control_auth.rb', line 66

def auth_mode
  configured = auth_settings[:mode] || auth_settings[:enabled]
  mode = case configured
         when true then 'required'
         when false then 'disabled'
         else configured.to_s
         end
  AUTH_MODES.include?(mode) ? mode : 'auto'
end

.auth_required?Boolean

Returns:

  • (Boolean)


76
77
78
# File 'lib/legion/extensions/node/control_auth.rb', line 76

def auth_required?
  auth_mode == 'required'
end

.auth_settingsObject



84
85
86
# File 'lib/legion/extensions/node/control_auth.rb', line 84

def auth_settings
  Legion::Extensions::Node::Config.control_auth
end

.canonical_json(value) ⇒ Object



110
111
112
# File 'lib/legion/extensions/node/control_auth.rb', line 110

def canonical_json(value)
  ::JSON.generate(canonicalize(value))
end

.canonicalize(value) ⇒ Object



114
115
116
117
118
119
120
121
122
123
124
125
# File 'lib/legion/extensions/node/control_auth.rb', line 114

def canonicalize(value)
  case value
  when Hash
    value.each_with_object({}) do |(key, val), result|
      result[key.to_s] = canonicalize(val)
    end.sort.to_h
  when Array
    value.map { |item| canonicalize(item) }
  else
    value
  end
end

.configured_secretObject



92
93
94
95
96
97
98
99
100
101
# File 'lib/legion/extensions/node/control_auth.rb', line 92

def configured_secret
  if defined?(Legion::Settings) && Legion::Settings.respond_to?(:dig)
    auth_settings[:secret] ||
      Legion::Settings.dig(:cluster, :control_secret) ||
      Legion::Settings.dig(:crypt, :cluster_secret)
  end
rescue StandardError => e
  log.debug("control secret lookup failed: #{e.message}")
  nil
end

.deep_symbolize(value) ⇒ Object



127
128
129
130
131
132
133
134
135
136
137
138
# File 'lib/legion/extensions/node/control_auth.rb', line 127

def deep_symbolize(value)
  case value
  when Hash
    value.each_with_object({}) do |(key, val), result|
      result[key.to_sym] = deep_symbolize(val)
    end
  when Array
    value.map { |item| deep_symbolize(item) }
  else
    value
  end
end

.fresh_timestamp?(timestamp) ⇒ Boolean

Returns:

  • (Boolean)


140
141
142
143
144
145
146
# File 'lib/legion/extensions/node/control_auth.rb', line 140

def fresh_timestamp?(timestamp)
  ts = Integer(timestamp)
  (Time.now.utc.to_i - ts).abs <= timestamp_skew_seconds
rescue ArgumentError, TypeError => e
  log.debug("control timestamp validation failed: #{e.message}")
  false
end

.logObject



180
181
182
# File 'lib/legion/extensions/node/control_auth.rb', line 180

def log
  Legion::Logging.respond_to?(:logger) ? Legion::Logging.logger : Legion::Logging
end

.node_nameObject



173
174
175
176
177
178
# File 'lib/legion/extensions/node/control_auth.rb', line 173

def node_name
  (Legion::Settings[:client]&.fetch(:name, nil) if defined?(Legion::Settings)) || 'unknown'
rescue StandardError => e
  log.debug("node name lookup failed: #{e.message}")
  'unknown'
end

.nonce_bytesObject



155
156
157
158
159
160
161
# File 'lib/legion/extensions/node/control_auth.rb', line 155

def nonce_bytes
  bytes = Integer(auth_settings[:nonce_bytes])
  bytes.positive? ? bytes : 16
rescue ArgumentError, TypeError => e
  log.debug("control nonce byte setting invalid: #{e.message}")
  16
end

.secretObject



88
89
90
# File 'lib/legion/extensions/node/control_auth.rb', line 88

def secret
  configured_secret || ENV.fetch('LEGION_NODE_CONTROL_SECRET', nil)
end

.secure_compare(left, right) ⇒ Object



167
168
169
170
171
# File 'lib/legion/extensions/node/control_auth.rb', line 167

def secure_compare(left, right)
  return false unless left.bytesize == right.bytesize

  left.bytes.zip(right.bytes).reduce(0) { |acc, (a, b)| acc | (a ^ b) }.zero?
end

.sign(payload) ⇒ Object



18
19
20
21
22
23
24
25
26
27
28
29
# File 'lib/legion/extensions/node/control_auth.rb', line 18

def sign(payload)
  normalized = deep_symbolize(payload).dup
  return normalized unless sign_control_messages?

  control = {
    sender:    node_name,
    timestamp: Time.now.utc.to_i,
    nonce:     SecureRandom.hex(nonce_bytes)
  }
  normalized[:control] = control.merge(signature: signature_for(normalized.merge(control: control)))
  normalized
end

.sign_control_messages?Boolean

Returns:

  • (Boolean)


52
53
54
55
56
57
# File 'lib/legion/extensions/node/control_auth.rb', line 52

def sign_control_messages?
  return false if auth_disabled?
  return true if auth_required?

  !secret.to_s.empty?
end

.signature_for(payload) ⇒ Object



103
104
105
106
107
108
# File 'lib/legion/extensions/node/control_auth.rb', line 103

def signature_for(payload)
  value = secret
  raise UnauthorizedControlMessage, 'cluster control secret is not configured' if value.to_s.empty?

  OpenSSL::HMAC.hexdigest('SHA256', value.to_s, canonical_json(payload))
end

.timestamp_skew_secondsObject



148
149
150
151
152
153
# File 'lib/legion/extensions/node/control_auth.rb', line 148

def timestamp_skew_seconds
  Integer(auth_settings[:timestamp_skew_seconds])
rescue ArgumentError, TypeError => e
  log.debug("control timestamp skew setting invalid: #{e.message}")
  300
end

.valid_nonce?(nonce) ⇒ Boolean

Returns:

  • (Boolean)


163
164
165
# File 'lib/legion/extensions/node/control_auth.rb', line 163

def valid_nonce?(nonce)
  nonce.to_s.match?(/\A[0-9a-f]{#{nonce_bytes * 2}}\z/i)
end

.verify!(payload) ⇒ Object



31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# File 'lib/legion/extensions/node/control_auth.rb', line 31

def verify!(payload)
  normalized = deep_symbolize(payload)
  raise UnauthorizedControlMessage, 'missing control signature' unless normalized.is_a?(Hash)
  return normalized unless verify_control_messages?

  control = normalized[:control]
  raise UnauthorizedControlMessage, 'missing control signature' unless control.is_a?(Hash)

  signature = control[:signature].to_s
  raise UnauthorizedControlMessage, 'missing control signature' if signature.empty?
  raise UnauthorizedControlMessage, 'stale control message' unless fresh_timestamp?(control[:timestamp])
  raise UnauthorizedControlMessage, 'invalid control nonce' unless valid_nonce?(control[:nonce])

  unsigned_control = control.dup
  unsigned_control.delete(:signature)
  expected = signature_for(normalized.merge(control: unsigned_control))
  raise UnauthorizedControlMessage, 'invalid control signature' unless secure_compare(signature, expected)

  normalized
end

.verify_control_messages?Boolean

Returns:

  • (Boolean)


59
60
61
62
63
64
# File 'lib/legion/extensions/node/control_auth.rb', line 59

def verify_control_messages?
  return false if auth_disabled?
  return true if auth_required?

  !secret.to_s.empty?
end