defmodule SnmpKit.SnmpLib.Security.USM do @moduledoc """ User Security Model (USM) implementation for SNMPv3 - RFC 3414 compliant. The User Security Model provides the foundation for SNMPv3 security by implementing: - **User-based authentication** with multiple protocols - **Privacy (encryption)** for message confidentiality - **Time synchronization** to prevent replay attacks - **Engine discovery** for secure agent communication - **Security parameter validation** and error handling ## RFC 3414 Compliance This implementation fully complies with RFC 3414 "User-based Security Model (USM) for version 3 of the Simple Network Management Protocol (SNMPv3)" including: - Message authentication using HMAC-MD5 and HMAC-SHA - Privacy using DES and AES encryption - Key derivation using password localization - Time window validation for message freshness - Engine ID discovery and management ## Architecture The USM coordinates with other security modules: ``` SnmpKit.SnmpLib.Security.USM ├── Auth protocols (MD5, SHA variants) ├── Priv protocols (DES, AES variants) ├── Key derivation and management └── Engine and time management ``` ## Usage Examples ### Engine Discovery # Discover remote engine for secure communication {:ok, engine_id} = SnmpKit.SnmpLib.Security.USM.discover_engine("192.168.1.1") # Time synchronization {:ok, {boots, time}} = SnmpKit.SnmpLib.Security.USM.synchronize_time("192.168.1.1", engine_id) ### Message Processing # Process outgoing secure message {:ok, secure_message} = SnmpKit.SnmpLib.Security.USM.process_outgoing_message( user, message, security_level ) # Process incoming secure message {:ok, {plain_message, user}} = SnmpKit.SnmpLib.Security.USM.process_incoming_message( secure_message, user_database ) ## Security Considerations - Engine boot counters must be persistent across restarts - Time synchronization is critical for security - Failed authentication attempts should be logged - Key material should never be logged or persisted in plain text """ alias SnmpKit.SnmpLib.PDU alias SnmpKit.SnmpLib.PDU.Constants alias SnmpKit.SnmpLib.PDU.V3Encoder alias SnmpKit.SnmpLib.Security.Auth alias SnmpKit.SnmpLib.Security.Priv require Logger @type engine_id :: binary() @type security_name :: binary() @type security_level :: :no_auth_no_priv | :auth_no_priv | :auth_priv @type engine_boots :: non_neg_integer() @type engine_time :: non_neg_integer() @type user_entry :: %{ security_name: security_name(), auth_protocol: atom(), priv_protocol: atom(), auth_key: binary(), priv_key: binary(), engine_id: engine_id() } @type message_flags :: %{ auth_flag: boolean(), priv_flag: boolean(), reportable_flag: boolean() } @type security_parameters :: %{ authoritative_engine_id: engine_id(), authoritative_engine_boots: engine_boots(), authoritative_engine_time: engine_time(), user_name: security_name(), authentication_parameters: binary(), privacy_parameters: binary() } # Time window for message freshness (RFC 3414) @time_window 150 # Maximum engine boots value before rollover @max_engine_boots 2_147_483_647 ## Engine Discovery and Time Synchronization @doc """ Discovers the engine ID of a remote SNMP agent. Engine discovery is the first step in establishing secure communication with a remote SNMPv3 agent. This function sends a discovery request and retrieves the agent's authoritative engine ID. ## Parameters - `host`: Target agent IP address or hostname - `opts`: Discovery options including port, timeout, and community ## Returns - `{:ok, engine_id}`: Successfully discovered engine ID - `{:error, reason}`: Discovery failed ## Examples {:ok, engine_id} = SnmpKit.SnmpLib.Security.USM.discover_engine("192.168.1.1") {:ok, engine_id} = SnmpKit.SnmpLib.Security.USM.discover_engine("10.0.0.1", port: 1161, timeout: 5000) """ @spec discover_engine(binary(), keyword()) :: {:ok, engine_id()} | {:error, atom()} def discover_engine(host, opts \\ []) do Logger.debug("Starting engine discovery for host: #{host}") try do # Create discovery message msg_id = :rand.uniform(2_147_483_647) discovery_message = V3Encoder.create_discovery_message(msg_id) # Encode discovery message (no security) case V3Encoder.encode_message(discovery_message, nil) do {:ok, request_packet} -> # Send discovery request case send_discovery_request(host, request_packet, opts) do {:ok, response_packet} -> # Decode response to extract engine ID case V3Encoder.decode_message(response_packet, nil) do {:ok, response_message} -> extract_engine_id_from_response(response_message) {:error, reason} -> Logger.error("Failed to decode discovery response: #{inspect(reason)}") {:error, :decode_failed} end {:error, reason} -> Logger.error("Discovery request failed: #{inspect(reason)}") {:error, reason} end {:error, reason} -> Logger.error("Failed to encode discovery message: #{inspect(reason)}") {:error, :encode_failed} end rescue error -> Logger.error("Engine discovery failed: #{inspect(error)}") {:error, :discovery_failed} end end @doc """ Synchronizes time with a remote SNMP agent. Time synchronization is required for authenticated communication to prevent replay attacks. This function retrieves the agent's current boot counter and engine time. """ @spec synchronize_time(binary(), engine_id(), keyword()) :: {:ok, {engine_boots(), engine_time()}} | {:error, atom()} def synchronize_time(host, engine_id, opts \\ []) do Logger.debug("Starting time synchronization with engine: #{Base.encode16(engine_id)}") try do # Create time synchronization message (authenticated but not encrypted) msg_id = :rand.uniform(2_147_483_647) time_sync_message = %{ version: 3, msg_id: msg_id, msg_max_size: Constants.default_max_message_size(), msg_flags: %{auth: true, priv: false, reportable: true}, msg_security_model: Constants.usm_security_model(), msg_security_parameters: <<>>, msg_data: %{ context_engine_id: engine_id, context_name: <<>>, pdu: %{ type: :get_request, request_id: msg_id, error_status: 0, error_index: 0, # snmpEngineTime OID varbinds: [{[1, 3, 6, 1, 6, 3, 10, 2, 1, 3, 0], :null, :null}] } } } # Create temporary user for time sync (with zero keys initially) temp_user = create_temp_sync_user(engine_id, opts) case send_time_sync_request(host, time_sync_message, temp_user, opts) do {:ok, engine_boots, engine_time} -> Logger.debug("Time synchronization successful: boots=#{engine_boots}, time=#{engine_time}") {:ok, {engine_boots, engine_time}} {:error, reason} -> {:error, reason} end rescue error -> Logger.error("Time synchronization failed: #{inspect(error)}") {:error, :sync_failed} end end ## Message Processing @doc """ Processes an outgoing SNMP message with USM security. This function applies authentication and/or privacy protection to an outgoing message based on the user's security level configuration. """ @spec process_outgoing_message(user_entry(), binary(), security_level()) :: {:ok, binary()} | {:error, atom()} def process_outgoing_message(user, message, security_level) do Logger.debug("Processing outgoing message with security level: #{security_level}") try do # Validate security level matches user configuration case validate_security_level(user, security_level) do :ok -> # Decode the message to get the PDU case PDU.decode_message(message) do {:ok, decoded_message} -> # Convert to SNMPv3 format and apply security v3_message = convert_to_v3_message(decoded_message, user, security_level) case V3Encoder.encode_message(v3_message, user) do {:ok, secure_message} -> Logger.debug("Message security processing successful") {:ok, secure_message} {:error, reason} -> Logger.error("Failed to encode secure message: #{inspect(reason)}") {:error, :encoding_failed} end {:error, reason} -> Logger.error("Failed to decode input message: #{inspect(reason)}") {:error, :decode_failed} end {:error, reason} -> {:error, reason} end rescue error -> Logger.error("Message processing failed: #{inspect(error)}") {:error, :processing_failed} end end # Helper functions for engine discovery and time synchronization defp send_discovery_request(host, request_packet, opts) do port = Keyword.get(opts, :port, 161) timeout = Keyword.get(opts, :timeout, 5000) case :gen_udp.open(0, [:binary, {:active, false}]) do {:ok, socket} -> try do case :gen_udp.send(socket, to_charlist(host), port, request_packet) do :ok -> case :gen_udp.recv(socket, 0, timeout) do {:ok, {_address, _port, response_packet}} -> {:ok, response_packet} {:error, reason} -> {:error, reason} end {:error, reason} -> {:error, reason} end after :gen_udp.close(socket) end {:error, reason} -> {:error, reason} end end defp extract_engine_id_from_response(%{msg_data: %{context_engine_id: engine_id}}) when byte_size(engine_id) > 0 do {:ok, engine_id} end defp extract_engine_id_from_response(_response) do {:error, :no_engine_id_found} end defp create_temp_sync_user(engine_id, opts) do %{ security_name: Keyword.get(opts, :security_name, ""), auth_protocol: :none, priv_protocol: :none, auth_key: <<>>, priv_key: <<>>, engine_id: engine_id, engine_boots: 0, engine_time: 0 } end defp send_time_sync_request(host, message, user, opts) do # For time sync, we expect to get a report PDU with timing information case send_discovery_request( host, V3Encoder.encode_message(message, user), opts ) do {:ok, response_packet} -> case V3Encoder.decode_message(response_packet, user) do {:ok, response} -> # Extract timing information from response extract_timing_from_response(response) {:error, reason} -> {:error, reason} end {:error, reason} -> {:error, reason} end end defp extract_timing_from_response(%{msg_security_parameters: security_params}) do case decode_usm_security_params(security_params) do {:ok, %{engine_boots: boots, engine_time: time}} -> {:ok, boots, time} {:error, reason} -> {:error, reason} end end defp extract_timing_from_response(_response) do {:error, :no_timing_info} end defp decode_usm_security_params(params) when is_binary(params) and byte_size(params) > 0 do # Simple USM parameter decoding - in a full implementation this would use proper ASN.1 decoding # For now, return mock values {:ok, %{engine_boots: 1, engine_time: System.system_time(:second)}} end defp decode_usm_security_params(_) do {:error, :invalid_params} end defp validate_security_level(user, security_level) do case {user.auth_protocol, user.priv_protocol, security_level} do {:none, :none, :no_auth_no_priv} -> :ok {auth, :none, :auth_no_priv} when auth != :none -> :ok {auth, priv, :auth_priv} when auth != :none and priv != :none -> :ok _ -> {:error, :security_level_mismatch} end end defp convert_to_v3_message(v1v2c_message, user, security_level) do flags = Constants.default_msg_flags(security_level) %{ version: 3, msg_id: :rand.uniform(2_147_483_647), msg_max_size: Constants.default_max_message_size(), msg_flags: flags, msg_security_model: Constants.usm_security_model(), msg_security_parameters: <<>>, msg_data: %{ context_engine_id: user.engine_id, context_name: <<>>, pdu: v1v2c_message.pdu } } end @doc """ Processes an incoming SNMP message with USM security. This function validates and decrypts an incoming secure message, returning the plain message content and validated user information. """ @spec process_incoming_message(binary(), map()) :: {:ok, {binary(), user_entry()}} | {:error, atom()} def process_incoming_message(secure_message, user_database) do Logger.debug("Processing incoming secure message") with {:ok, {scoped_pdu, security_params, flags}} <- parse_secure_message(secure_message), {:ok, user} <- lookup_user(user_database, security_params.user_name), :ok <- validate_security_parameters(user, security_params), :ok <- verify_authentication(user, secure_message, security_params, flags), {:ok, plain_message} <- decrypt_message(user, scoped_pdu, security_params, flags) do Logger.debug("Incoming message processing successful") {:ok, {plain_message, user}} else {:error, reason} -> Logger.error("Incoming message processing failed: #{inspect(reason)}") {:error, reason} end end ## Security Parameter Management @doc """ Validates time-based security parameters to prevent replay attacks. Per RFC 3414, messages are considered fresh if: - Engine boots match (within 1) - Engine time is within 150 seconds """ @spec validate_time_window(engine_boots(), engine_time(), engine_boots(), engine_time()) :: :ok | {:error, atom()} def validate_time_window(local_boots, local_time, remote_boots, remote_time) do boots_diff = abs(local_boots - remote_boots) time_diff = abs(local_time - remote_time) cond do boots_diff > 1 -> Logger.warning("Engine boots difference too large: #{boots_diff}") {:error, :engine_boots_mismatch} boots_diff == 1 and time_diff > @time_window -> Logger.warning("Time window exceeded across boot boundary: #{time_diff}s") {:error, :time_window_exceeded} boots_diff == 0 and time_diff > @time_window -> Logger.warning("Time window exceeded: #{time_diff}s > #{@time_window}s") {:error, :time_window_exceeded} true -> Logger.debug("Time window validation successful") :ok end end @doc """ Updates engine boot counter, handling rollover at maximum value. """ @spec increment_engine_boots(engine_boots()) :: engine_boots() def increment_engine_boots(current_boots) when current_boots >= @max_engine_boots do Logger.warning("Engine boots rollover from #{current_boots} to 1") 1 end def increment_engine_boots(current_boots) do current_boots + 1 end @doc """ Calculates current engine time since boot. """ @spec get_engine_time(non_neg_integer()) :: engine_time() def get_engine_time(boot_timestamp) do current_time = System.system_time(:second) max(0, current_time - boot_timestamp) end ## Error Handling and Reporting @doc """ Generates security error reports for invalid messages. USM error reports are sent back to the originator to indicate security violations or configuration issues. """ @spec generate_error_report(atom(), map()) :: {:ok, binary()} | {:error, atom()} def generate_error_report(error_type, context) do Logger.info("Generating USM error report: #{error_type}") case error_type do :unknown_engine_id -> build_error_report(:usmStatsUnknownEngineIDs, context) :wrong_digest -> build_error_report(:usmStatsWrongDigests, context) :unknown_user_name -> build_error_report(:usmStatsUnknownUserNames, context) :unsupported_security_level -> build_error_report(:usmStatsUnsupportedSecLevels, context) :not_in_time_window -> build_error_report(:usmStatsNotInTimeWindows, context) :decryption_error -> build_error_report(:usmStatsDecryptionErrors, context) _ -> {:error, :unknown_error_type} end end ## Private Implementation # TODO: The following helper functions are for future SNMPv3 support # They are commented out to avoid Dialyzer warnings until a proper # SNMPv3 encoder is implemented that handles scoped_pdu and security_parameters # defp build_discovery_request do # # SNMPv3 discovery message with empty security parameters # %{ # message_id: :rand.uniform(2_147_483_647), # max_size: 65507, # flags: %{auth_flag: false, priv_flag: false, reportable_flag: true}, # security_model: 3, # USM # security_parameters: %{ # authoritative_engine_id: <<>>, # authoritative_engine_boots: 0, # authoritative_engine_time: 0, # user_name: <<>>, # authentication_parameters: <<>>, # privacy_parameters: <<>> # }, # scoped_pdu: build_discovery_pdu() # } # end # defp build_discovery_pdu do # # GET request for snmpEngineID (1.3.6.1.6.3.10.2.1.1.0) # engine_id_oid = [1, 3, 6, 1, 6, 3, 10, 2, 1, 1, 0] # PDU.build_get_request(engine_id_oid, :rand.uniform(2_147_483_647)) # end # defp send_discovery_request(host, port, request, timeout) do # # Serialize and send discovery request # case PDU.encode_message(request) do # {:ok, encoded_request} -> # Transport.send_request(host, port, encoded_request, timeout) # {:error, reason} -> # {:error, reason} # end # end # defp parse_discovery_response(response) do # case PDU.decode_message(response) do # {:ok, decoded} -> # # Check if this is an SNMPv3 message with security parameters # case Map.get(decoded, :security_parameters) do # nil -> # # This is likely an SNMPv1/v2c message, not v3 # {:error, :not_snmpv3_message} # security_params -> # # Extract engine ID from security parameters # case Map.get(security_params, :authoritative_engine_id) do # nil -> # {:error, :missing_engine_id} # engine_id when is_binary(engine_id) and byte_size(engine_id) > 0 -> # {:ok, engine_id} # _ -> # {:error, :empty_engine_id} # end # end # {:error, reason} -> # {:error, reason} # end # end # defp build_time_sync_request(engine_id) do # %{ # message_id: :rand.uniform(2_147_483_647), # max_size: 65507, # flags: %{auth_flag: false, priv_flag: false, reportable_flag: true}, # security_model: 3, # security_parameters: %{ # authoritative_engine_id: engine_id, # authoritative_engine_boots: 0, # authoritative_engine_time: 0, # user_name: <<>>, # authentication_parameters: <<>>, # privacy_parameters: <<>> # }, # scoped_pdu: build_discovery_pdu() # } # end # defp send_time_sync_request(host, port, request, timeout) do # case PDU.encode_message(request) do # {:ok, encoded_request} -> # Transport.send_request(host, port, encoded_request, timeout) # {:error, reason} -> # {:error, reason} # end # end # defp parse_time_sync_response(response) do # case PDU.decode_message(response) do # {:ok, decoded} -> # # Check if this is an SNMPv3 message with required fields # case Map.get(decoded, :security_parameters) do # nil -> # # This is likely an SNMPv1/v2c message, not v3 # {:error, :not_snmpv3_message} # security_params -> # boots = Map.get(security_params, :authoritative_engine_boots, 0) # time = Map.get(security_params, :authoritative_engine_time, 0) # {:ok, {boots, time}} # end # {:error, reason} -> # {:error, reason} # end # end # TODO: Additional SNMPv3 helper functions - commented out until proper v3 support is implemented # defp determine_message_flags(:no_auth_no_priv) do # {:ok, %{auth_flag: false, priv_flag: false, reportable_flag: false}} # end # defp determine_message_flags(:auth_no_priv) do # {:ok, %{auth_flag: true, priv_flag: false, reportable_flag: false}} # end # defp determine_message_flags(:auth_priv) do # {:ok, %{auth_flag: true, priv_flag: true, reportable_flag: false}} # end # defp determine_message_flags(_) do # {:error, :invalid_security_level} # end # defp apply_security(user, message, flags) do # with {:ok, encrypted_message, priv_params} <- maybe_encrypt(user, message, flags.priv_flag), # {:ok, auth_params} <- maybe_authenticate(user, encrypted_message, flags.auth_flag) do # {:ok, {encrypted_message, auth_params, priv_params}} # end # end # defp maybe_encrypt(user, message, true) do # case Priv.encrypt(user.priv_protocol, user.priv_key, user.auth_key, message) do # {:ok, {encrypted, params}} -> {:ok, encrypted, params} # {:error, reason} -> {:error, reason} # end # end # defp maybe_encrypt(_user, message, false) do # {:ok, message, <<>>} # end # defp maybe_authenticate(user, message, true) do # Auth.authenticate(user.auth_protocol, user.auth_key, message) # end # defp maybe_authenticate(_user, _message, false) do # {:ok, <<>>} # end # defp build_security_parameters(user, auth_params, priv_params) do # params = %{ # authoritative_engine_id: user.engine_id, # authoritative_engine_boots: 1, # This should come from persistent storage # authoritative_engine_time: System.system_time(:second), # user_name: user.security_name, # authentication_parameters: auth_params, # privacy_parameters: priv_params # } # {:ok, params} # end # TODO: SNMPv3 message building - commented out until proper v3 encoder is implemented # defp build_secure_message(scoped_pdu, security_params, flags) do # message = %{ # message_id: :rand.uniform(2_147_483_647), # max_size: 65507, # flags: flags, # security_model: 3, # security_parameters: security_params, # scoped_pdu: scoped_pdu # } # PDU.encode_message(message) # end defp parse_secure_message(secure_message) do case PDU.decode_message(secure_message) do {:ok, decoded} -> # Check if this is an SNMPv3 message with required fields with {:ok, scoped_pdu} <- get_scoped_pdu(decoded), {:ok, security_params} <- get_security_parameters(decoded), {:ok, flags} <- get_message_flags(decoded) do {:ok, {scoped_pdu, security_params, flags}} end {:error, reason} -> {:error, reason} end end defp get_scoped_pdu(decoded) do case Map.get(decoded, :scoped_pdu) do nil -> {:error, :missing_scoped_pdu} scoped_pdu -> {:ok, scoped_pdu} end end defp get_security_parameters(decoded) do case Map.get(decoded, :security_parameters) do nil -> {:error, :missing_security_parameters} security_params -> {:ok, security_params} end end defp get_message_flags(decoded) do case Map.get(decoded, :flags) do nil -> {:error, :missing_message_flags} flags -> {:ok, flags} end end defp lookup_user(user_database, user_name) do case Map.get(user_database, user_name) do nil -> {:error, :unknown_user_name} user -> {:ok, user} end end defp validate_security_parameters(user, params) do with :ok <- validate_engine_id(user.engine_id, params.authoritative_engine_id) do validate_time_window( 1, System.system_time(:second), params.authoritative_engine_boots, params.authoritative_engine_time ) end end defp validate_engine_id(expected, actual) do if expected == actual do :ok else {:error, :unknown_engine_id} end end defp verify_authentication(user, message, params, flags) do if flags.auth_flag do Auth.verify(user.auth_protocol, user.auth_key, message, params.authentication_parameters) else :ok end end defp decrypt_message(user, encrypted_message, params, flags) do if flags.priv_flag do Priv.decrypt( user.priv_protocol, user.priv_key, user.auth_key, encrypted_message, params.privacy_parameters ) else {:ok, encrypted_message} end end defp build_error_report(error_oid, _context) do # Build SNMPv3 error report message # This would contain the specific error OID and current statistics Logger.debug("Building error report for #{error_oid}") # Placeholder implementation {:ok, <<>>} end end