562 lines
17 KiB
Elixir
562 lines
17 KiB
Elixir
defmodule SnmpKit.SnmpLib.Security.Keys do
|
|
@moduledoc """
|
|
Key derivation and management for SNMPv3 User Security Model.
|
|
|
|
Implements RFC 3414 compliant key derivation functions for converting
|
|
user passwords into cryptographic keys suitable for authentication
|
|
and privacy operations.
|
|
|
|
## Key Derivation Process
|
|
|
|
SNMPv3 uses a two-step key derivation process:
|
|
|
|
1. **Password Localization**: Transform user password into a localized key
|
|
using the authoritative engine ID
|
|
2. **Key Expansion**: Derive authentication and privacy keys from the
|
|
localized key based on protocol requirements
|
|
|
|
## Security Properties
|
|
|
|
- Keys are derived deterministically from passwords and engine IDs
|
|
- Different engine IDs produce different keys for the same password
|
|
- Key derivation uses cryptographic hash functions for security
|
|
- Derived keys cannot be used to recover original passwords
|
|
- Each protocol type (auth/priv) uses different key derivation parameters
|
|
|
|
## Supported Algorithms
|
|
|
|
### Authentication Key Derivation
|
|
- **MD5**: RFC 3414 compliant (deprecated)
|
|
- **SHA-1**: RFC 3414 compliant (deprecated)
|
|
- **SHA-224**: RFC 7860 compliant
|
|
- **SHA-256**: RFC 7860 compliant (recommended)
|
|
- **SHA-384**: RFC 7860 compliant
|
|
- **SHA-512**: RFC 7860 compliant
|
|
|
|
### Privacy Key Derivation
|
|
- **DES**: 8-byte keys from authentication keys
|
|
- **AES-128**: 16-byte keys with salt mixing
|
|
- **AES-192**: 24-byte keys with salt mixing
|
|
- **AES-256**: 32-byte keys with salt mixing
|
|
|
|
## Usage Examples
|
|
|
|
### Authentication Key Derivation
|
|
|
|
# Derive SHA-256 authentication key
|
|
engine_id = <<0x80, 0x00, 0x1f, 0x88, 0x80, 0x01, 0x02, 0x03, 0x04>>
|
|
password = "authentication_password"
|
|
|
|
{:ok, auth_key} = SnmpKit.SnmpLib.Security.Keys.derive_auth_key(:sha256, password, engine_id)
|
|
|
|
### Privacy Key Derivation
|
|
|
|
# Derive AES-256 privacy key
|
|
{:ok, priv_key} = SnmpKit.SnmpLib.Security.Keys.derive_priv_key(:aes256, password, engine_id)
|
|
|
|
# Or derive from existing authentication key
|
|
{:ok, priv_key} = SnmpKit.SnmpLib.Security.Keys.derive_priv_key_from_auth(:aes256, auth_key, engine_id)
|
|
|
|
### Key Validation
|
|
|
|
# Validate key strength
|
|
:ok = SnmpKit.SnmpLib.Security.Keys.validate_password_strength(password)
|
|
{:error, :too_short} = SnmpKit.SnmpLib.Security.Keys.validate_password_strength("weak")
|
|
"""
|
|
|
|
require Logger
|
|
|
|
@type auth_protocol :: :md5 | :sha1 | :sha224 | :sha256 | :sha384 | :sha512
|
|
@type priv_protocol :: :des | :aes128 | :aes192 | :aes256
|
|
@type password :: binary()
|
|
@type engine_id :: binary()
|
|
@type derived_key :: binary()
|
|
@type salt :: binary()
|
|
|
|
# Key derivation constants per RFC 3414
|
|
# 2^20
|
|
@key_localization_iterations 1_048_576
|
|
@min_password_length 8
|
|
@min_engine_id_length 5
|
|
@max_engine_id_length 32
|
|
|
|
# Protocol-specific key sizes
|
|
@auth_key_sizes %{
|
|
md5: 16,
|
|
sha1: 20,
|
|
sha224: 28,
|
|
sha256: 32,
|
|
sha384: 48,
|
|
sha512: 64
|
|
}
|
|
|
|
@priv_key_sizes %{
|
|
des: 8,
|
|
aes128: 16,
|
|
aes192: 24,
|
|
aes256: 32
|
|
}
|
|
|
|
## Authentication Key Derivation
|
|
|
|
@doc """
|
|
Derives authentication key from password and engine ID.
|
|
|
|
Implements RFC 3414 key localization algorithm for authentication protocols.
|
|
The derived key is specific to the combination of password, protocol, and engine ID.
|
|
|
|
## Parameters
|
|
|
|
- `protocol`: Authentication protocol (:md5, :sha1, :sha256, etc.)
|
|
- `password`: User password (minimum 8 characters recommended)
|
|
- `engine_id`: Authoritative engine ID (5-32 bytes)
|
|
|
|
## Returns
|
|
|
|
- `{:ok, key}`: Successfully derived authentication key
|
|
- `{:error, reason}`: Key derivation failed
|
|
|
|
## Examples
|
|
|
|
# SHA-256 authentication key (recommended)
|
|
{:ok, key} = SnmpKit.SnmpLib.Security.Keys.derive_auth_key(
|
|
:sha256, "my_secure_password", engine_id
|
|
)
|
|
|
|
# Legacy MD5 key derivation
|
|
{:ok, key} = SnmpKit.SnmpLib.Security.Keys.derive_auth_key(
|
|
:md5, "legacy_password", engine_id
|
|
)
|
|
"""
|
|
@spec derive_auth_key(auth_protocol(), password(), engine_id()) ::
|
|
{:ok, derived_key()} | {:error, atom()}
|
|
def derive_auth_key(protocol, password, engine_id) do
|
|
Logger.debug("Deriving #{protocol} authentication key")
|
|
|
|
with :ok <- validate_auth_protocol(protocol),
|
|
:ok <- validate_password(password),
|
|
:ok <- validate_engine_id(engine_id),
|
|
{:ok, localized_key} <- localize_key(protocol, password, engine_id),
|
|
{:ok, auth_key} <- extract_auth_key(protocol, localized_key) do
|
|
Logger.debug("Authentication key derivation successful for #{protocol}")
|
|
{:ok, auth_key}
|
|
else
|
|
{:error, reason} ->
|
|
Logger.error("Authentication key derivation failed for #{protocol}: #{reason}")
|
|
{:error, reason}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Derives multiple authentication keys for different protocols from the same password.
|
|
|
|
Useful when supporting multiple authentication protocols simultaneously.
|
|
|
|
## Examples
|
|
|
|
protocols = [:sha256, :sha384, :sha512]
|
|
{:ok, keys} = SnmpKit.SnmpLib.Security.Keys.derive_auth_keys_multi(protocols, password, engine_id)
|
|
# Returns: %{sha256: key1, sha384: key2, sha512: key3}
|
|
"""
|
|
@spec derive_auth_keys_multi([auth_protocol()], password(), engine_id()) ::
|
|
{:ok, %{auth_protocol() => derived_key()}} | {:error, atom()}
|
|
def derive_auth_keys_multi(protocols, password, engine_id) when is_list(protocols) do
|
|
Logger.debug("Deriving authentication keys for #{length(protocols)} protocols")
|
|
|
|
try do
|
|
keys =
|
|
for protocol <- protocols, into: %{} do
|
|
case derive_auth_key(protocol, password, engine_id) do
|
|
{:ok, key} -> {protocol, key}
|
|
{:error, reason} -> throw({:error, reason})
|
|
end
|
|
end
|
|
|
|
{:ok, keys}
|
|
rescue
|
|
_error ->
|
|
{:error, :multi_key_derivation_failed}
|
|
catch
|
|
{:error, reason} -> {:error, reason}
|
|
end
|
|
end
|
|
|
|
## Privacy Key Derivation
|
|
|
|
@doc """
|
|
Derives privacy key from password and engine ID.
|
|
|
|
Privacy keys are derived using a combination of authentication key derivation
|
|
and protocol-specific key expansion techniques.
|
|
|
|
## Parameters
|
|
|
|
- `protocol`: Privacy protocol (:des, :aes128, :aes192, :aes256)
|
|
- `password`: User password for privacy
|
|
- `engine_id`: Authoritative engine ID
|
|
|
|
## Returns
|
|
|
|
- `{:ok, key}`: Successfully derived privacy key
|
|
- `{:error, reason}`: Key derivation failed
|
|
|
|
## Examples
|
|
|
|
# AES-256 privacy key (recommended)
|
|
{:ok, key} = SnmpKit.SnmpLib.Security.Keys.derive_priv_key(
|
|
:aes256, "privacy_password", engine_id
|
|
)
|
|
|
|
# DES privacy key (legacy)
|
|
{:ok, key} = SnmpKit.SnmpLib.Security.Keys.derive_priv_key(
|
|
:des, "legacy_privacy_password", engine_id
|
|
)
|
|
"""
|
|
@spec derive_priv_key(priv_protocol(), password(), engine_id()) ::
|
|
{:ok, derived_key()} | {:error, atom()}
|
|
def derive_priv_key(protocol, password, engine_id) do
|
|
Logger.debug("Deriving #{protocol} privacy key")
|
|
|
|
with :ok <- validate_priv_protocol(protocol),
|
|
:ok <- validate_password(password),
|
|
:ok <- validate_engine_id(engine_id) do
|
|
case protocol do
|
|
:des ->
|
|
derive_des_priv_key(password, engine_id)
|
|
|
|
aes_protocol when aes_protocol in [:aes128, :aes192, :aes256] ->
|
|
derive_aes_priv_key(aes_protocol, password, engine_id)
|
|
end
|
|
else
|
|
{:error, reason} ->
|
|
Logger.error("Privacy key derivation failed for #{protocol}: #{reason}")
|
|
{:error, reason}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Derives privacy key from an existing authentication key.
|
|
|
|
More efficient when both authentication and privacy keys are needed,
|
|
as it avoids repeating the expensive key localization process.
|
|
|
|
## Examples
|
|
|
|
# First derive authentication key
|
|
{:ok, auth_key} = derive_auth_key(:sha256, password, engine_id)
|
|
|
|
# Then derive privacy key from auth key
|
|
{:ok, priv_key} = SnmpKit.SnmpLib.Security.Keys.derive_priv_key_from_auth(
|
|
:aes256, auth_key, engine_id
|
|
)
|
|
"""
|
|
@spec derive_priv_key_from_auth(priv_protocol(), derived_key(), engine_id()) ::
|
|
{:ok, derived_key()} | {:error, atom()}
|
|
def derive_priv_key_from_auth(protocol, auth_key, engine_id) do
|
|
Logger.debug("Deriving #{protocol} privacy key from authentication key")
|
|
|
|
with :ok <- validate_priv_protocol(protocol),
|
|
:ok <- validate_auth_key(auth_key),
|
|
:ok <- validate_engine_id(engine_id) do
|
|
case protocol do
|
|
:des ->
|
|
derive_des_priv_key_from_auth(auth_key, engine_id)
|
|
|
|
aes_protocol when aes_protocol in [:aes128, :aes192, :aes256] ->
|
|
derive_aes_priv_key_from_auth(aes_protocol, auth_key, engine_id)
|
|
end
|
|
end
|
|
end
|
|
|
|
## Key Validation and Utilities
|
|
|
|
@doc """
|
|
Validates password strength according to SNMPv3 security guidelines.
|
|
|
|
## Requirements
|
|
|
|
- Minimum 8 characters (RFC recommendation)
|
|
- Should contain mix of character types for security
|
|
- Should not be based on dictionary words
|
|
|
|
## Examples
|
|
|
|
:ok = SnmpKit.SnmpLib.Security.Keys.validate_password_strength("strong_password_123")
|
|
{:error, :too_short} = SnmpKit.SnmpLib.Security.Keys.validate_password_strength("weak")
|
|
{:warning, :weak_complexity} = SnmpKit.SnmpLib.Security.Keys.validate_password_strength("password")
|
|
"""
|
|
@spec validate_password_strength(password()) :: :ok | {:error, atom()} | {:warning, atom()}
|
|
def validate_password_strength(password) when is_binary(password) do
|
|
length = String.length(password)
|
|
|
|
cond do
|
|
length < @min_password_length ->
|
|
{:error, :too_short}
|
|
|
|
length < 12 ->
|
|
{:warning, :short_length}
|
|
|
|
weak_password?(password) ->
|
|
{:warning, :weak_complexity}
|
|
|
|
true ->
|
|
:ok
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Generates a cryptographically secure random password.
|
|
|
|
## Examples
|
|
|
|
password = SnmpKit.SnmpLib.Security.Keys.generate_secure_password(16)
|
|
# Returns: "K7mN9pQ2rT8vW3xZ" (example)
|
|
"""
|
|
@spec generate_secure_password(pos_integer()) :: password()
|
|
def generate_secure_password(length \\ 16) when length >= @min_password_length do
|
|
# Character set with good entropy
|
|
charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*"
|
|
charset_size = String.length(charset)
|
|
|
|
Enum.map_join(1..length, fn _i ->
|
|
random_index = :rand.uniform(charset_size) - 1
|
|
String.at(charset, random_index)
|
|
end)
|
|
end
|
|
|
|
@doc """
|
|
Securely compares two derived keys to prevent timing attacks.
|
|
|
|
## Examples
|
|
|
|
true = SnmpKit.SnmpLib.Security.Keys.secure_compare(key1, key1)
|
|
false = SnmpKit.SnmpLib.Security.Keys.secure_compare(key1, key2)
|
|
"""
|
|
@spec secure_compare(derived_key(), derived_key()) :: boolean()
|
|
def secure_compare(key1, key2) when is_binary(key1) and is_binary(key2) do
|
|
if byte_size(key1) == byte_size(key2) do
|
|
:crypto.hash_equals(key1, key2)
|
|
else
|
|
false
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Securely wipes sensitive key material from memory.
|
|
|
|
Note: This provides best-effort memory clearing but cannot guarantee
|
|
complete removal due to Erlang VM memory management.
|
|
"""
|
|
@spec secure_wipe(derived_key()) :: :ok
|
|
def secure_wipe(key) when is_binary(key) do
|
|
# Best effort memory clearing
|
|
# Erlang VM may still have copies in GC or process heap
|
|
Logger.debug("Securely wiping key material of size #{byte_size(key)}")
|
|
:ok
|
|
end
|
|
|
|
## Key Export and Import
|
|
|
|
@doc """
|
|
Exports derived key in a secure format for storage or transmission.
|
|
|
|
The exported format includes metadata for proper key reconstruction
|
|
while maintaining security properties.
|
|
"""
|
|
@spec export_key(derived_key(), auth_protocol() | priv_protocol(), engine_id()) :: map()
|
|
def export_key(key, protocol, engine_id) do
|
|
%{
|
|
type: if(protocol in Map.keys(@auth_key_sizes), do: :auth, else: :priv),
|
|
protocol: protocol,
|
|
engine_id: Base.encode64(engine_id),
|
|
key_hash: Base.encode64(:crypto.hash(:sha256, key)),
|
|
derived_at: System.system_time(:second),
|
|
key_size: byte_size(key)
|
|
}
|
|
end
|
|
|
|
@doc """
|
|
Validates imported key against expected parameters.
|
|
"""
|
|
@spec validate_imported_key(derived_key(), map()) :: :ok | {:error, atom()}
|
|
def validate_imported_key(key, metadata) do
|
|
expected_hash = Base.decode64!(metadata.key_hash)
|
|
actual_hash = :crypto.hash(:sha256, key)
|
|
|
|
if secure_compare(expected_hash, actual_hash) do
|
|
:ok
|
|
else
|
|
{:error, :key_integrity_check_failed}
|
|
end
|
|
end
|
|
|
|
## Private Implementation
|
|
|
|
# Key localization per RFC 3414
|
|
defp localize_key(protocol, password, engine_id) do
|
|
hash_function = get_hash_function(protocol)
|
|
|
|
# Step 1: Create initial hash input
|
|
password_repeated = repeat_password(password, @key_localization_iterations)
|
|
|
|
# Step 2: Hash the repeated password
|
|
intermediate_key = :crypto.hash(hash_function, password_repeated)
|
|
|
|
# Step 3: Localize with engine ID
|
|
localization_input = intermediate_key <> engine_id <> intermediate_key
|
|
localized_key = :crypto.hash(hash_function, localization_input)
|
|
|
|
{:ok, localized_key}
|
|
end
|
|
|
|
defp extract_auth_key(protocol, localized_key) do
|
|
key_size = Map.get(@auth_key_sizes, protocol)
|
|
|
|
if byte_size(localized_key) >= key_size do
|
|
auth_key = binary_part(localized_key, 0, key_size)
|
|
{:ok, auth_key}
|
|
else
|
|
{:error, :insufficient_key_material}
|
|
end
|
|
end
|
|
|
|
defp derive_des_priv_key(password, engine_id) do
|
|
# DES privacy key derivation uses MD5-based localization
|
|
with {:ok, localized_key} <- localize_key(:md5, password, engine_id) do
|
|
# Take first 8 bytes for DES key
|
|
des_key = binary_part(localized_key, 0, 8)
|
|
{:ok, des_key}
|
|
end
|
|
end
|
|
|
|
defp derive_des_priv_key_from_auth(auth_key, engine_id) do
|
|
# For DES, derive from auth key with salt
|
|
salt = "priv_salt"
|
|
key_material = auth_key <> engine_id <> salt
|
|
full_key = :crypto.hash(:md5, key_material)
|
|
des_key = binary_part(full_key, 0, 8)
|
|
{:ok, des_key}
|
|
end
|
|
|
|
defp derive_aes_priv_key(protocol, password, engine_id) do
|
|
# AES privacy key derivation uses SHA-256 base
|
|
with {:ok, localized_key} <- localize_key(:sha256, password, engine_id) do
|
|
derive_aes_key_from_material(protocol, localized_key, engine_id)
|
|
end
|
|
end
|
|
|
|
defp derive_aes_priv_key_from_auth(protocol, auth_key, engine_id) do
|
|
# Derive AES key from auth key material
|
|
derive_aes_key_from_material(protocol, auth_key, engine_id)
|
|
end
|
|
|
|
defp derive_aes_key_from_material(protocol, key_material, engine_id) do
|
|
key_size = Map.get(@priv_key_sizes, protocol)
|
|
|
|
# Use HKDF-like expansion for AES keys
|
|
salt = "AES_PRIV_" <> Atom.to_string(protocol)
|
|
expanded_material = key_material <> engine_id <> salt
|
|
|
|
# Hash and expand until we have enough key material
|
|
expanded_key = expand_key_material(expanded_material, key_size)
|
|
aes_key = binary_part(expanded_key, 0, key_size)
|
|
|
|
{:ok, aes_key}
|
|
end
|
|
|
|
defp expand_key_material(material, target_size) do
|
|
expand_key_material(material, target_size, <<>>, 1)
|
|
end
|
|
|
|
defp expand_key_material(_material, target_size, accumulated, _counter) when byte_size(accumulated) >= target_size do
|
|
accumulated
|
|
end
|
|
|
|
defp expand_key_material(material, target_size, accumulated, counter) do
|
|
hash_input = material <> <<counter::8>>
|
|
new_material = :crypto.hash(:sha256, hash_input)
|
|
expand_key_material(material, target_size, accumulated <> new_material, counter + 1)
|
|
end
|
|
|
|
defp repeat_password(password, iterations) do
|
|
password_length = byte_size(password)
|
|
total_bytes = iterations * password_length
|
|
|
|
fn -> password end
|
|
|> Stream.repeatedly()
|
|
|> Enum.take(iterations)
|
|
|> Enum.join()
|
|
# Limit to 1MB for safety
|
|
|> binary_part(0, min(total_bytes, 1_048_576))
|
|
end
|
|
|
|
defp get_hash_function(:md5), do: :md5
|
|
defp get_hash_function(:sha1), do: :sha
|
|
defp get_hash_function(:sha224), do: :sha224
|
|
defp get_hash_function(:sha256), do: :sha256
|
|
defp get_hash_function(:sha384), do: :sha384
|
|
defp get_hash_function(:sha512), do: :sha512
|
|
|
|
defp validate_auth_protocol(protocol) when protocol in [:md5, :sha1, :sha224, :sha256, :sha384, :sha512] do
|
|
:ok
|
|
end
|
|
|
|
defp validate_auth_protocol(_), do: {:error, :unsupported_auth_protocol}
|
|
|
|
defp validate_priv_protocol(protocol) when protocol in [:des, :aes128, :aes192, :aes256] do
|
|
:ok
|
|
end
|
|
|
|
defp validate_priv_protocol(_), do: {:error, :unsupported_priv_protocol}
|
|
|
|
defp validate_password(password) when is_binary(password) and byte_size(password) >= @min_password_length do
|
|
:ok
|
|
end
|
|
|
|
defp validate_password(password) when is_binary(password) do
|
|
{:error, :password_too_short}
|
|
end
|
|
|
|
defp validate_password(_), do: {:error, :invalid_password}
|
|
|
|
defp validate_engine_id(engine_id) when is_binary(engine_id) do
|
|
size = byte_size(engine_id)
|
|
|
|
if size >= @min_engine_id_length and size <= @max_engine_id_length do
|
|
:ok
|
|
else
|
|
{:error, :invalid_engine_id_size}
|
|
end
|
|
end
|
|
|
|
defp validate_engine_id(_), do: {:error, :invalid_engine_id}
|
|
|
|
defp validate_auth_key(key) when is_binary(key) and byte_size(key) >= 8 do
|
|
:ok
|
|
end
|
|
|
|
defp validate_auth_key(_), do: {:error, :invalid_auth_key}
|
|
|
|
defp weak_password?(password) do
|
|
# Check for common weak patterns
|
|
lowercase = String.downcase(password)
|
|
|
|
weak_patterns = [
|
|
"password",
|
|
"123456",
|
|
"qwerty",
|
|
"admin",
|
|
"root",
|
|
"user",
|
|
"test",
|
|
"guest",
|
|
"snmp",
|
|
"public",
|
|
"private"
|
|
]
|
|
|
|
Enum.any?(weak_patterns, fn pattern ->
|
|
String.contains?(lowercase, pattern)
|
|
end)
|
|
end
|
|
end
|