defmodule SnmpKit.SnmpLib.PDU.V3Encoder do @moduledoc """ SNMPv3 message encoding and decoding with User Security Model (USM) support. This module implements the SNMPv3 message format as specified in RFC 3412 and RFC 3414, providing authentication and privacy protection for SNMP communications. ## SNMPv3 Message Structure SNMPv3 messages have a complex hierarchical structure: ``` SNMPv3Message ::= SEQUENCE { msgVersion INTEGER (0..2147483647), msgGlobalData HeaderData, msgSecurityParameters OCTET STRING, msgData ScopedPduData } HeaderData ::= SEQUENCE { msgID INTEGER (0..2147483647), msgMaxSize INTEGER (484..2147483647), msgFlags OCTET STRING (SIZE(1)), msgSecurityModel INTEGER (1..2147483647) } ScopedPduData ::= CHOICE { plaintext ScopedPDU, encryptedPDU OCTET STRING } ScopedPDU ::= SEQUENCE { contextEngineID OCTET STRING, contextName OCTET STRING, data ANY } ``` ## Security Processing The module integrates with the security subsystem to provide: - Message authentication using HMAC algorithms - Message encryption using AES/DES algorithms - Time synchronization and engine discovery - Replay attack protection ## Usage Examples ### Encoding a SNMPv3 Message # Create security user user = %{ security_name: "testuser", auth_protocol: :sha256, priv_protocol: :aes128, auth_key: derived_auth_key, priv_key: derived_priv_key, engine_id: "local_engine" } # Create SNMPv3 message message = %{ version: 3, msg_id: 12345, msg_max_size: 65507, msg_flags: %{auth: true, priv: true, reportable: true}, msg_security_model: 3, msg_security_parameters: "", # Will be generated msg_data: %{ context_engine_id: "target_engine", context_name: "", pdu: pdu } } # Encode with security {:ok, encoded} = SnmpKit.SnmpLib.PDU.V3Encoder.encode_message(message, user) ### Decoding a SNMPv3 Message {:ok, decoded} = SnmpKit.SnmpLib.PDU.V3Encoder.decode_message(binary_data, user) ## Security Notes - Authentication is required for privacy (encryption) - Engine discovery must be performed before authenticated communication - Time synchronization is required to prevent replay attacks - Message IDs should be unique to prevent duplicate processing """ import Bitwise alias SnmpKit.SnmpLib.ASN1 alias SnmpKit.SnmpLib.PDU.Constants alias SnmpKit.SnmpLib.PDU.Decoder alias SnmpKit.SnmpLib.PDU.Encoder alias SnmpKit.SnmpLib.Security require Logger @type v3_message :: Constants.v3_message() @type scoped_pdu :: Constants.scoped_pdu() @type security_user :: Security.security_user() @type security_params :: Security.security_params() # ASN.1 tags @sequence 0x30 @doc """ Encodes a SNMPv3 message with security processing. ## Parameters - `message` - SNMPv3 message structure - `user` - Security user configuration (optional for discovery messages) ## Returns - `{:ok, binary()}` on success - `{:error, reason}` on failure ## Examples {:ok, encoded} = encode_message(snmpv3_message, security_user) {:ok, discovery_msg} = encode_message(discovery_message, nil) """ @spec encode_message(v3_message(), security_user() | nil) :: {:ok, binary()} | {:error, atom()} def encode_message(message, user \\ nil) def encode_message(%{version: 3} = message, user) do # Encode scoped PDU case encode_scoped_pdu(message.msg_data) do {:ok, scoped_pdu_data} -> # Apply security processing case apply_security_processing(message, scoped_pdu_data, user) do {:ok, final_msg_data, security_params} -> # Encode complete message encode_v3_message(message, security_params, final_msg_data) {:error, reason} -> {:error, reason} end {:error, reason} -> {:error, reason} end rescue error -> Logger.error("SNMPv3 encoding failed: #{inspect(error)}") {:error, :encoding_failed} end def encode_message(%{version: version}, _user) when version != 3 do {:error, :invalid_version} end def encode_message(_, _) do {:error, :invalid_message_format} end @doc """ Decodes a SNMPv3 message with security processing. ## Parameters - `data` - Binary SNMPv3 message data - `user` - Security user configuration (optional for discovery messages) ## Returns - `{:ok, message}` on success - `{:error, reason}` on failure """ @spec decode_message(binary(), security_user() | nil) :: {:ok, v3_message()} | {:error, atom()} def decode_message(data, user \\ nil) when is_binary(data) do with {:ok, message, security_params, msg_data} <- decode_v3_message(data), {:ok, result} <- process_security_parameters(message, security_params, msg_data, user) do finalize_decoded_message(message, result, user) end rescue error -> Logger.error("SNMPv3 decoding failed: #{inspect(error)}") {:error, :decoding_failed} end defp finalize_decoded_message(message, result, nil) do # For discovery messages, process_security_parameters already returns the scoped_pdu final_message = Map.put(message, :msg_data, result) {:ok, final_message} end defp finalize_decoded_message(message, decrypted_data, _user) do # Decode scoped PDU case decode_scoped_pdu(decrypted_data) do {:ok, scoped_pdu} -> final_message = Map.put(message, :msg_data, scoped_pdu) {:ok, final_message} {:error, reason} -> {:error, reason} end end # Private encoding functions defp encode_v3_message(message, security_params, msg_data) do # Build complete message with {:ok, version_data} <- ASN1.encode_integer(message.version), {:ok, header_data} <- encode_header_data( message.msg_id, message.msg_max_size, message.msg_flags, message.msg_security_model ), {:ok, security_data} <- ASN1.encode_octet_string(security_params), {:ok, msg_data_encoded} <- encode_msg_data_for_transport(msg_data, message.msg_flags) do iodata = [ version_data, header_data, security_data, msg_data_encoded ] content = :erlang.iolist_to_binary(iodata) encode_sequence(content) end end defp encode_msg_data_for_transport(msg_data, msg_flags) do # For encrypted messages (priv=true), msg_data is encrypted binary that needs OCTET STRING wrapper # For plaintext messages, msg_data is scoped PDU binary that should remain as sequence data if msg_flags.priv do ASN1.encode_octet_string(msg_data) else # Plaintext scoped PDU data is already properly encoded as sequence {:ok, msg_data} end end defp encode_header_data(msg_id, msg_max_size, msg_flags, security_model) do flags_binary = Constants.encode_msg_flags(msg_flags) with {:ok, msg_id_data} <- ASN1.encode_integer(msg_id), {:ok, size_data} <- ASN1.encode_integer(msg_max_size), {:ok, flags_data} <- ASN1.encode_octet_string(flags_binary), {:ok, model_data} <- ASN1.encode_integer(security_model) do iodata = [ msg_id_data, size_data, flags_data, model_data ] content = :erlang.iolist_to_binary(iodata) encode_sequence(content) end end defp encode_scoped_pdu(%{context_engine_id: engine_id, context_name: name, pdu: pdu}) do case Encoder.encode_pdu(pdu) do {:ok, pdu_data} -> # Instead of trying to construct iodata directly, let's validate each component with {:ok, engine_data} <- ASN1.encode_octet_string(engine_id), {:ok, name_data} <- ASN1.encode_octet_string(name) do try do # Build the sequence content directly without intermediate iodata content = <> {:ok, encoded_seq} = encode_sequence(content) {:ok, encoded_seq} rescue error -> Logger.error( "Scoped PDU encoding failed: #{inspect(error)} - engine_id: #{inspect(engine_id)}, name: #{inspect(name)}, pdu_data size: #{byte_size(pdu_data)}" ) {:error, :scoped_pdu_encoding_failed} end end {:error, reason} -> {:error, reason} end end defp apply_security_processing(_message, scoped_pdu_data, nil) do # No security processing for discovery messages {:ok, scoped_pdu_data, <<>>} end defp apply_security_processing(message, scoped_pdu_data, user) do flags = message.msg_flags cond do flags.priv -> # Authentication and Privacy apply_auth_priv_processing(message, scoped_pdu_data, user) flags.auth -> # Authentication only apply_auth_processing(message, scoped_pdu_data, user) true -> # No security {:ok, scoped_pdu_data, encode_usm_security_params(user, <<>>, <<>>)} end end defp apply_auth_processing(message, scoped_pdu_data, user) do # Create security parameters with placeholder for authentication # 12-byte placeholder auth_placeholder = :binary.copy(<<0>>, 12) security_params = encode_usm_security_params(user, auth_placeholder, <<>>) # Build message for authentication temp_msg_data = scoped_pdu_data auth_message = build_auth_message(message, security_params, temp_msg_data) # Calculate authentication parameters case Security.authenticate_message(user, auth_message) do {:ok, auth_params} -> # Replace placeholder with actual authentication final_security_params = encode_usm_security_params(user, auth_params, <<>>) {:ok, scoped_pdu_data, final_security_params} {:error, reason} -> {:error, reason} end end defp apply_auth_priv_processing(message, scoped_pdu_data, user) do # Encrypt the scoped PDU case Security.Priv.encrypt(user.priv_protocol, user.priv_key, user.auth_key, scoped_pdu_data) do {:ok, {encrypted_data, priv_params}} -> # Apply authentication to encrypted data auth_placeholder = :binary.copy(<<0>>, 12) security_params = encode_usm_security_params(user, auth_placeholder, priv_params) # Build message for authentication auth_message = build_auth_message(message, security_params, encrypted_data) case Security.authenticate_message(user, auth_message) do {:ok, auth_params} -> final_security_params = encode_usm_security_params(user, auth_params, priv_params) {:ok, encrypted_data, final_security_params} {:error, reason} -> {:error, reason} end {:error, reason} -> {:error, reason} end end defp encode_usm_security_params(user, auth_params, priv_params) do # USM Security Parameters format with {:ok, engine_data} <- ASN1.encode_octet_string(user.engine_id), {:ok, boots_data} <- ASN1.encode_integer(user.engine_boots), {:ok, time_data} <- ASN1.encode_integer(user.engine_time), {:ok, name_data} <- ASN1.encode_octet_string(user.security_name), {:ok, auth_data} <- ASN1.encode_octet_string(auth_params), {:ok, priv_data} <- ASN1.encode_octet_string(priv_params) do iodata = [ engine_data, boots_data, time_data, name_data, auth_data, priv_data ] content = :erlang.iolist_to_binary(iodata) {:ok, result} = encode_sequence(content) result end end defp build_auth_message(message, security_params, msg_data) do # Build complete message for authentication calculation with {:ok, version_data} <- ASN1.encode_integer(message.version), {:ok, header_data} <- encode_header_data( message.msg_id, message.msg_max_size, message.msg_flags, message.msg_security_model ), {:ok, security_params_data} <- ASN1.encode_octet_string(security_params) do iodata = [ version_data, header_data, security_params_data, msg_data ] content = :erlang.iolist_to_binary(iodata) {:ok, auth_message} = encode_sequence(content) auth_message end end # Private decoding functions defp decode_v3_message(data) do with {:ok, {content, _remaining}} <- ASN1.decode_sequence(data), {:ok, version, header_data, security_params, msg_data} <- decode_message_components(content), :ok <- validate_version(version), {:ok, msg_id, msg_max_size, msg_flags, security_model} <- decode_header_data(header_data) do message = %{ version: version, msg_id: msg_id, msg_max_size: msg_max_size, msg_flags: msg_flags, msg_security_model: security_model } {:ok, message, security_params, msg_data} end end defp validate_version(3), do: :ok defp validate_version(version), do: {:error, {:invalid_version, version}} defp decode_message_components(data) do with {:ok, {version, rest1}} <- ASN1.decode_integer(data), {:ok, {header_data, rest2}} <- ASN1.decode_sequence(rest1), {:ok, {security_params, rest3}} <- ASN1.decode_octet_string(rest2), {:ok, msg_data, _rest4} <- decode_msg_data(rest3) do {:ok, version, header_data, security_params, msg_data} end end defp decode_header_data(data) do with {:ok, {msg_id, rest1}} <- ASN1.decode_integer(data), {:ok, {msg_max_size, rest2}} <- ASN1.decode_integer(rest1), {:ok, {msg_flags_binary, rest3}} <- ASN1.decode_octet_string(rest2), {:ok, {security_model, _rest4}} <- ASN1.decode_integer(rest3) do msg_flags = Constants.decode_msg_flags(msg_flags_binary) {:ok, msg_id, msg_max_size, msg_flags, security_model} end end defp decode_msg_data(data) do # msg_data can be either plaintext ScopedPDU or encrypted OCTET STRING # Try OCTET STRING first (encrypted data), then return raw data for plaintext case ASN1.decode_octet_string(data) do {:ok, {encrypted_data, remaining}} -> {:ok, encrypted_data, remaining} {:error, _} -> # For plaintext, return the raw data (which should be a complete SEQUENCE) {:ok, data, <<>>} end end defp decode_scoped_pdu(data) do case ASN1.decode_sequence(data) do {:ok, {content, _remaining}} -> with {:ok, {context_engine_id, rest1}} <- ASN1.decode_octet_string(content), {:ok, {context_name, rest2}} <- ASN1.decode_octet_string(rest1), {:ok, pdu} <- Decoder.decode_pdu(rest2) do scoped_pdu = %{ context_engine_id: context_engine_id, context_name: context_name, pdu: pdu } {:ok, scoped_pdu} end {:error, reason} -> {:error, reason} end end defp process_security_parameters(_message, _security_params, msg_data, nil) do # No security processing for discovery messages # For discovery messages, msg_data might be raw SEQUENCE data # First try to decode as SEQUENCE to get content, then process content case ASN1.decode_sequence(msg_data) do {:ok, {sequence_content, _}} -> # Now process the sequence content with {:ok, {context_engine_id, rest1}} <- ASN1.decode_octet_string(sequence_content), {:ok, {context_name, rest2}} <- ASN1.decode_octet_string(rest1), {:ok, pdu} <- Decoder.decode_pdu(rest2) do scoped_pdu = %{ context_engine_id: context_engine_id, context_name: context_name, pdu: pdu } {:ok, scoped_pdu} end {:error, _} -> # msg_data is already sequence content, try direct processing with {:ok, {context_engine_id, rest1}} <- ASN1.decode_octet_string(msg_data), {:ok, {context_name, rest2}} <- ASN1.decode_octet_string(rest1), {:ok, pdu} <- Decoder.decode_pdu(rest2) do scoped_pdu = %{ context_engine_id: context_engine_id, context_name: context_name, pdu: pdu } {:ok, scoped_pdu} end end end defp process_security_parameters(message, security_params, msg_data, user) do case decode_usm_security_params(security_params) do {:ok, usm_params} -> flags = message.msg_flags cond do flags.priv -> # Decrypt and verify authentication process_auth_priv_message(message, usm_params, msg_data, user) flags.auth -> # Verify authentication only process_auth_message(message, usm_params, msg_data, user) true -> # No authentication or privacy {:ok, msg_data} end {:error, reason} -> {:error, reason} end end defp process_auth_message(message, usm_params, msg_data, user) do # Build message for authentication verification auth_placeholder = :binary.copy(<<0>>, 12) temp_security_params = encode_usm_security_params( user, auth_placeholder, usm_params.priv_params ) # For auth-only messages, msg_data should remain as sequence (not OCTET STRING wrapped) auth_message = build_auth_message(message, temp_security_params, msg_data) case Security.Auth.verify( user.auth_protocol, user.auth_key, auth_message, usm_params.auth_params ) do :ok -> {:ok, msg_data} {:error, reason} -> {:error, reason} end end defp process_auth_priv_message(message, usm_params, encrypted_data, user) do # First verify authentication auth_placeholder = :binary.copy(<<0>>, 12) temp_security_params = encode_usm_security_params( user, auth_placeholder, usm_params.priv_params ) # Use raw encrypted data for authentication, same as during encoding # OCTET STRING wrapping happens later for transport, not for authentication auth_message = build_auth_message(message, temp_security_params, encrypted_data) case Security.Auth.verify( user.auth_protocol, user.auth_key, auth_message, usm_params.auth_params ) do :ok -> # Decrypt the message Security.Priv.decrypt( user.priv_protocol, user.priv_key, user.auth_key, encrypted_data, usm_params.priv_params ) {:error, reason} -> {:error, reason} end end defp decode_usm_security_params(data) do case ASN1.decode_sequence(data) do {:ok, {content, _remaining}} -> with {:ok, {engine_id, rest1}} <- ASN1.decode_octet_string(content), {:ok, {engine_boots, rest2}} <- ASN1.decode_integer(rest1), {:ok, {engine_time, rest3}} <- ASN1.decode_integer(rest2), {:ok, {security_name, rest4}} <- ASN1.decode_octet_string(rest3), {:ok, {auth_params, rest5}} <- ASN1.decode_octet_string(rest4), {:ok, {priv_params, _rest6}} <- ASN1.decode_octet_string(rest5) do usm_params = %{ engine_id: engine_id, engine_boots: engine_boots, engine_time: engine_time, security_name: security_name, auth_params: auth_params, priv_params: priv_params } {:ok, usm_params} end {:error, reason} -> {:error, reason} end end # Utility functions defp encode_sequence(content) do length = byte_size(content) {:ok, <<@sequence, encode_length(length)::binary, content::binary>>} end defp encode_length(length) when length < 128 do <> end defp encode_length(length) do bytes = encode_length_bytes(length, []) byte_count = byte_size(bytes) <<0x80 ||| byte_count, bytes::binary>> end defp encode_length_bytes(0, acc), do: :erlang.list_to_binary(acc) defp encode_length_bytes(length, acc) do encode_length_bytes(length >>> 8, [length &&& 0xFF | acc]) end @doc """ Creates a discovery message for engine ID discovery. """ @spec create_discovery_message(non_neg_integer()) :: v3_message() def create_discovery_message(msg_id \\ 4 |> :crypto.strong_rand_bytes() |> :binary.decode_unsigned()) do %{ version: 3, msg_id: msg_id, msg_max_size: Constants.default_max_message_size(), msg_flags: %{auth: false, priv: false, reportable: true}, msg_security_model: Constants.usm_security_model(), msg_security_parameters: <<>>, msg_data: %{ context_engine_id: <<>>, context_name: <<>>, pdu: %{ type: :get_request, request_id: msg_id, error_status: 0, error_index: 0, # snmpEngineID varbinds: [{[1, 3, 6, 1, 6, 3, 10, 2, 1, 1, 0], :null, :null}] } } } end end