towerops/lib/snmpkit/snmp_lib/pdu/v3_encoder.ex

680 lines
21 KiB
Elixir

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 = <<engine_data::binary, name_data::binary, pdu_data::binary>>
{: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
<<length>>
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