diff --git a/docker-compose.yml b/docker-compose.yml index ae7feaa..31e9496 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -61,3 +61,18 @@ services: - --v3-priv-key=maplesyrup - --v3-priv-proto=DES - --v3-user=unsafe + - --v3-user=authprivsha224aes + - --v3-auth-key=maplesyrup + - --v3-auth-proto=SHA224 + - --v3-priv-key=maplesyrup + - --v3-priv-proto=AES + - --v3-user=authprivsha384aes192 + - --v3-auth-key=maplesyrup + - --v3-auth-proto=SHA384 + - --v3-priv-key=maplesyrup + - --v3-priv-proto=AES192 + - --v3-user=authprivsha512aes256 + - --v3-auth-key=maplesyrup + - --v3-auth-proto=SHA512 + - --v3-priv-key=maplesyrup + - --v3-priv-proto=AES256 diff --git a/lib/netsnmp/encryption/aes.rb b/lib/netsnmp/encryption/aes.rb index 2d2b8ee..88cf3c3 100644 --- a/lib/netsnmp/encryption/aes.rb +++ b/lib/netsnmp/encryption/aes.rb @@ -2,19 +2,31 @@ module NETSNMP module Encryption + # https://www.rfc-editor.org/rfc/rfc3826 + # https://snmp.com/snmpv3/snmpv3_aes256.shtml + # Note: AES Blumental is not supported and not widely used class AES - def initialize(priv_key, local: 0) + def initialize(priv_key, cipher: , local: 0) @priv_key = priv_key @local = local + @cipher = cipher end def encrypt(decrypted_data, engine_boots:, engine_time:) - cipher = OpenSSL::Cipher.new("aes-128-cfb") + cipher = case @cipher + when :aes, :aes128 then OpenSSL::Cipher.new("aes-128-cfb") + when :aes192 then OpenSSL::Cipher.new("aes-192-cfb") + when :aes256 then OpenSSL::Cipher.new("aes-256-cfb") + end iv, salt = generate_encryption_key(engine_boots, engine_time) cipher.encrypt - cipher.iv = iv + cipher.iv = case @cipher + when :aes, :aes128 then iv[0, 16] + when :aes192 then iv[0, 24] + when :aes256 then iv[0, 32] + end cipher.key = aes_key if (diff = decrypted_data.length % 8) != 0 @@ -29,14 +41,22 @@ def encrypt(decrypted_data, engine_boots:, engine_time:) def decrypt(encrypted_data, salt:, engine_boots:, engine_time:) raise Error, "invalid priv salt received" unless !salt.empty? && (salt.length % 8).zero? - cipher = OpenSSL::Cipher.new("aes-128-cfb") + cipher = case @cipher + when :aes, :aes128 then OpenSSL::Cipher.new("aes-128-cfb") + when :aes192 then OpenSSL::Cipher.new("aes-192-cfb") + when :aes256 then OpenSSL::Cipher.new("aes-256-cfb") + end cipher.padding = 0 iv = generate_decryption_key(engine_boots, engine_time, salt) cipher.decrypt cipher.key = aes_key - cipher.iv = iv + cipher.iv = case @cipher + when :aes, :aes128 then iv[0..16] + when :aes192 then iv[0..24] + when :aes256 then iv[0..32] + end decrypted_data = cipher.update(encrypted_data) + cipher.final hlen, bodylen = OpenSSL::ASN1.traverse(decrypted_data) { |_, _, x, y, *| break x, y } @@ -58,6 +78,11 @@ def generate_encryption_key(boots, time) @local = @local == 0xffffffffffffffff ? 0 : @local + 1 iv = generate_decryption_key(boots, time, salt) + iv = case @cipher + when :aes, :aes128 then iv[0, 16] + when :aes192 then iv[0, 24] + when :aes256 then iv[0, 32] + end [iv, salt] end @@ -74,7 +99,11 @@ def generate_decryption_key(boots, time, salt) end def aes_key - @priv_key[0, 16] + case @cipher + when :aes, :aes128 then @priv_key[0, 16] + when :aes192 then @priv_key[0, 24] + when :aes256 then @priv_key[0, 32] + end end end end diff --git a/lib/netsnmp/message.rb b/lib/netsnmp/message.rb index a8047da..c10dacc 100644 --- a/lib/netsnmp/message.rb +++ b/lib/netsnmp/message.rb @@ -18,7 +18,7 @@ def initialize(**args) end def verify(stream, auth_param, security_level, security_parameters:) - security_parameters.verify(stream.sub(auth_param, authnone(security_parameters.auth_protocol).value), auth_param, security_level: security_level) + security_parameters.verify(stream.sub(auth_param, authnone(security_parameters).value), auth_param, security_level: security_level) end # @param [String] payload of an snmp v3 message which can be decoded @@ -119,7 +119,7 @@ def encode(pdu, security_parameters:, engine_boots: 0, engine_time: 0) log { "signing V3 message..." } auth_salt = OpenSSL::ASN1::OctetString.new(signature).with_label(:auth) log(level: 2) { auth_salt.to_hex } - none_der = authnone(security_parameters.auth_protocol).to_der + none_der = authnone(security_parameters).to_der encoded[encoded.index(none_der), none_der.size] = auth_salt.to_der log { Hexdump.dump(encoded) } end @@ -130,15 +130,12 @@ def encode(pdu, security_parameters:, engine_boots: 0, engine_time: 0) # https://datatracker.ietf.org/doc/html/rfc7860#section-4.2.2 part 3 # https://datatracker.ietf.org/doc/html/rfc3414#section-6.3.2 part 3 - def authnone(auth_protocol) + def authnone(security_parameters) # https://datatracker.ietf.org/doc/html/rfc3414#section-3.1 part 8b - return OpenSSL::ASN1::OctetString.new("").with_label(:auth_mask) unless auth_protocol + return OpenSSL::ASN1::OctetString.new("").with_label(:auth_mask) unless security_parameters&.auth_protocol - # The digest in the msgAuthenticationParameters field is replaced by the 12 zero octets. - # 24 octets for sha256 - number_of_octets = auth_protocol == :sha256 ? 24 : 12 - - OpenSSL::ASN1::OctetString.new("\x00" * number_of_octets).with_label(:auth_mask) + # The digest in the msgAuthenticationParameters field is replaced by zero octets. + OpenSSL::ASN1::OctetString.new("\x00" * security_parameters.digest_length).with_label(:auth_mask) end end end diff --git a/lib/netsnmp/security_parameters.rb b/lib/netsnmp/security_parameters.rb index 43fa655..5229f7e 100644 --- a/lib/netsnmp/security_parameters.rb +++ b/lib/netsnmp/security_parameters.rb @@ -15,6 +15,36 @@ class SecurityParameters IPAD = "\x36" * 64 OPAD = "\x5c" * 64 + DIGEST_LENGTH = { + md5: 12, + sha: 12, + sha1: 12, + sha224: 16, + sha256: 24, + sha384: 32, + sha512: 48 + }.freeze + + AUTH_KEY_LENGTH = { + nil: 16, + md5: 16, + sha: 20, + sha1: 20, + sha224: 28, + sha256: 32, + sha384: 48, + sha512: 64 + }.freeze + + PRIV_KEY_LENGTH = { + des: 16, + aes: 16, + aes128: 16, + aes192: 24, + aes256: 32, + nil: 16 + }.freeze + # Timeliness is part of SNMP V3 Security # The topic is described very nice here https://www.snmpsharpnet.com/?page_id=28 # https://www.ietf.org/rfc/rfc2574.txt 1.4.1 Timeliness @@ -129,11 +159,17 @@ def sign(message) return unless @auth_protocol key = auth_key.dup + case @auth_protocol + when :sha224, :sha256, :sha384, :sha512 then sign_sha2(key, message) + when :md5, :sha, :sha1 then sign_md5_or_sha(key, message) + end + end - # SHA256 => https://datatracker.ietf.org/doc/html/rfc7860#section-4.2.2 - # The 24 first octets of HMAC are taken as the computed MAC value - return OpenSSL::HMAC.digest("SHA256", key, message)[0, 24] if @auth_protocol == :sha256 + def sign_sha2(key, message) + OpenSSL::HMAC.digest(@auth_protocol.to_s.upcase, key, message)[0, digest_length] + end + def sign_md5_or_sha(key, message) # MD5 => https://datatracker.ietf.org/doc/html/rfc3414#section-6.3.2 # SHA1 => https://datatracker.ietf.org/doc/html/rfc3414#section-7.3.2 key << ("\x00" * (@auth_protocol == :md5 ? 48 : 44)) @@ -147,7 +183,7 @@ def sign(message) digest.reset digest << (k2 + d1) # The 12 first octets of the digest are taken as the computed MAC value - digest.digest[0, 12] + digest.digest[0, digest_length] end # @param [String] stream the encoded incoming payload @@ -170,17 +206,29 @@ def must_revalidate? (Process.clock_gettime(Process::CLOCK_MONOTONIC, :second) - @timeliness) >= TIMELINESS_THRESHOLD end + def digest_length + DIGEST_LENGTH[@auth_protocol] || raise(Error, "unknown digest length for #{@auth_protocol}") + end + private + def auth_key_length + AUTH_KEY_LENGTH[@auth_protocol] + end + + def priv_key_length + PRIV_KEY_LENGTH[@priv_protocol] + end + def auth_key - @auth_key ||= localize_key(@auth_pass_key) + @auth_key ||= localize_auth_key(@auth_pass_key) end def priv_key - @priv_key ||= localize_key(@priv_pass_key) + @priv_key ||= localize_priv_key(@priv_pass_key) end - def localize_key(key) + def localize_auth_key(key) digest.reset digest << key digest << @engine_id @@ -189,6 +237,15 @@ def localize_key(key) digest.digest end + # AES-192, AES-256 require longer localized keys, + # which require adding of subsequent localized_priv_keys based on the previous until the length is satisfied + # The only hint to this is available in the python implementation called pysnmp + def localize_priv_key(key) + dig = localize_auth_key(key) + dig += localize_auth_key(passkey(dig)) while dig.length < priv_key_length + dig + end + def passkey(password) digest.reset password_index = 0 @@ -205,24 +262,31 @@ def passkey(password) end dig = digest.digest - dig = dig[0, 16] if @auth_protocol == :md5 + dig = dig[0, auth_key_length] if @auth_protocol dig || "" end def digest @digest ||= case @auth_protocol when :md5 then OpenSSL::Digest.new("MD5") - when :sha then OpenSSL::Digest.new("SHA1") + when :sha, :sha1 then OpenSSL::Digest.new("SHA1") + when :sha224 then OpenSSL::Digest.new("SHA224") when :sha256 then OpenSSL::Digest.new("SHA256") + when :sha384 then OpenSSL::Digest.new("SHA384") + when :sha512 then OpenSSL::Digest.new("SHA512") else raise Error, "unsupported auth protocol: #{@auth_protocol}" end end + + def encryption @encryption ||= case @priv_protocol when :des then Encryption::DES.new(priv_key) - when :aes then Encryption::AES.new(priv_key) + when :aes then Encryption::AES.new(priv_key, cipher: @priv_protocol) + when :aes192 then Encryption::AES.new(priv_key, cipher: @priv_protocol) + when :aes256 then Encryption::AES.new(priv_key, cipher: @priv_protocol) end end diff --git a/spec/client_spec.rb b/spec/client_spec.rb index 87d5ec8..7988184 100644 --- a/spec/client_spec.rb +++ b/spec/client_spec.rb @@ -221,6 +221,36 @@ let(:protocol_options) { version_options.merge(user_options).merge(extra_options) } end end + context "auth in sha224, encrypting in aes" do + let(:user_options) do + { username: "authprivsha224aes", auth_password: "maplesyrup", + auth_protocol: :sha224, priv_password: "maplesyrup", + priv_protocol: :aes } + end + it_behaves_like "an snmp client" do + let(:protocol_options) { version_options.merge(user_options).merge(extra_options) } + end + end + context "auth in sha384, encrypting in aes192" do + let(:user_options) do + { username: "authprivsha384aes192", auth_password: "maplesyrup", + auth_protocol: :sha384, priv_password: "maplesyrup", + priv_protocol: :aes192 } + end + it_behaves_like "an snmp client" do + let(:protocol_options) { version_options.merge(user_options).merge(extra_options) } + end + end + context "auth in sha512, encrypting in aes256" do + let(:user_options) do + { username: "authprivsha512aes256", auth_password: "maplesyrup", + auth_protocol: :sha512, priv_password: "maplesyrup", + priv_protocol: :aes256 } + end + it_behaves_like "an snmp client" do + let(:protocol_options) { version_options.merge(user_options).merge(extra_options) } + end + end end end end diff --git a/spec/security_parameters_spec.rb b/spec/security_parameters_spec.rb index 5c0127c..6dd2296 100644 --- a/spec/security_parameters_spec.rb +++ b/spec/security_parameters_spec.rb @@ -13,6 +13,13 @@ subject { described_class.new(security_level: :auth_priv, auth_protocol: :sha, username: "username", engine_id: engine_id, auth_password: "maplesyrup", priv_password: "maplesyrup") } it { expect(subject.send(:passkey, password).b).to eq("\x9f\xb5\xcc\x03\x81\x49\x7b\x37\x93\x52\x89\x39\xff\x78\x8d\x5d\x79\x14\x52\x11".b) } end + context "sha224" do + subject do + described_class.new(security_level: :auth_priv, auth_protocol: :sha224, username: "username", engine_id: engine_id, auth_password: "maplesyrup", priv_password: "maplesyrup") + end + + it { expect(subject.send(:passkey, password).b).to eq("(*Xg\xEE\x9A\xACc\x9A\xD5\x9D\xF9W,}:\xC0\xFB\xC1:\x90[m\xF0}\xBB\xF0\v".b) } + end context "sha256" do subject do described_class.new(security_level: :auth_priv, auth_protocol: :sha256, username: "username", engine_id: engine_id, auth_password: "maplesyrup", priv_password: "maplesyrup") @@ -20,6 +27,20 @@ it { expect(subject.send(:passkey, password).b).to eq("\xABQ\x01M\x1E\a\x7F`\x17\xDF+\x12\xBE\xE5\xF5\xAAr\x991w\xE9\xBBV\x9CM\xFFZL\xA0\xB4\xAF\xAC".b) } end + context "sha384" do + subject do + described_class.new(security_level: :auth_priv, auth_protocol: :sha384, username: "username", engine_id: engine_id, auth_password: "maplesyrup", priv_password: "maplesyrup") + end + + it { expect(subject.send(:passkey, password).b).to eq("\xE0n\xCC\xDF,h\xA0n\xD04r<\x9C&\xE0\xDB;f\x9E\x1E.\xFE\xD4\x91P\xB5Sw\xA2\xE9\x8F8<\x86\xFB\x83hWDFT\xB2\x87\xC9?Q\xFFd".b) } + end + context "sha512" do + subject do + described_class.new(security_level: :auth_priv, auth_protocol: :sha512, username: "username", engine_id: engine_id, auth_password: "maplesyrup", priv_password: "maplesyrup") + end + + it { expect(subject.send(:passkey, password).b).to eq("~C\x96\xDEZ\xAD\xC7{\xE8S\x81\x9B\x98\xC9@be\xB3\xA9\xC3|\xC3\x17ei\x84zNOo\xBAc\xDD:s\xD0I$\xD3\x1Ac\xF9Z`\x1F\x93\x85\xAFk\xE4\xED\e7\xF8}\x04\x0F|n\xD6\xF8\xD3\x8A\x91".b) } + end end describe "keys" do @@ -41,6 +62,15 @@ priv_password: password, engine_id: engine_id) end + let(:sha224_sec) do + described_class.new(security_level: :auth_priv, + auth_protocol: :sha224, + priv_protocol: :des, + username: "username", + auth_password: password, + priv_password: password, + engine_id: engine_id) + end let(:sha256_sec) do described_class.new(security_level: :auth_priv, auth_protocol: :sha256, @@ -50,13 +80,38 @@ priv_password: password, engine_id: engine_id) end + let(:sha384_sec) do + described_class.new(security_level: :auth_priv, + auth_protocol: :sha384, + priv_protocol: :des, + username: "username", + auth_password: password, + priv_password: password, + engine_id: engine_id) + end + let(:sha512_sec) do + described_class.new(security_level: :auth_priv, + auth_protocol: :sha512, + priv_protocol: :des, + username: "username", + auth_password: password, + priv_password: password, + engine_id: engine_id) + end it do expect(md5_sec.send(:auth_key)).to eq("\x52\x6f\x5e\xed\x9f\xcc\xe2\x6f\x89\x64\xc2\x93\x07\x87\xd8\x2b".b) expect(md5_sec.send(:priv_key)).to eq("\x52\x6f\x5e\xed\x9f\xcc\xe2\x6f\x89\x64\xc2\x93\x07\x87\xd8\x2b".b) expect(sha_sec.send(:auth_key)).to eq("\x66\x95\xfe\xbc\x92\x88\xe3\x62\x82\x23\x5f\xc7\x15\x1f\x12\x84\x97\xb3\x8f\x3f".b) expect(sha_sec.send(:priv_key)).to eq("\x66\x95\xfe\xbc\x92\x88\xe3\x62\x82\x23\x5f\xc7\x15\x1f\x12\x84\x97\xb3\x8f\x3f".b) + expect(sha224_sec.send(:auth_key)).to eq("\v\xD8\x82|n)\xF8\x06^\b\xE0\x927\xF1w\xE4\x10\xF6\x9B\x90\xE1x+\xE6\x82\aVt".b) + expect(sha224_sec.send(:priv_key)).to eq("\v\xD8\x82|n)\xF8\x06^\b\xE0\x927\xF1w\xE4\x10\xF6\x9B\x90\xE1x+\xE6\x82\aVt".b) expect(sha256_sec.send(:auth_key)).to eq("\x89\x82\xE0\xE5I\xE8f\xDB6\x1Akb]\x84\xCC\xCC\x11\x16-E>\xE8\xCE:dE\xC2\xD6wo\x0F\x8B".b) expect(sha256_sec.send(:priv_key)).to eq("\x89\x82\xE0\xE5I\xE8f\xDB6\x1Akb]\x84\xCC\xCC\x11\x16-E>\xE8\xCE:dE\xC2\xD6wo\x0F\x8B".b) + expect(sha384_sec.send(:auth_key)).to eq(";)\x8F\x16\x16J\x11\x18By\xD5C+\xF1i\xE2\xD2\xA4\x83\a\xDE\x02\xB3\xD3\xF7\xE2\xB4\xF3n\xB6\xF0EZSh\x9A97\xEE\xA0s\x19\xA63\xD2\xCC\xBAx".b) + expect(sha384_sec.send(:priv_key)).to eq(";)\x8F\x16\x16J\x11\x18By\xD5C+\xF1i\xE2\xD2\xA4\x83\a\xDE\x02\xB3\xD3\xF7\xE2\xB4\xF3n\xB6\xF0EZSh\x9A97\xEE\xA0s\x19\xA63\xD2\xCC\xBAx".b) + expect(sha512_sec.send(:auth_key)).to eq("\"\xA5\xA3l\xED\xFC\xC0\x85\x80z\x12\x8D{\xC6\xC28!g\xADl\r\xBC_\xDF\xF8Vt\x0F=\x84\xC0\x99\xAD\x1E\xA8z\x8D\xB0\x96qM\x97\x88\xBDT@G\xC9\x02\x1EB)\xCE'\xE4\xC0\xA6\x92P\xAD\xFC\xFF\xBB\v".b) + expect(sha512_sec.send(:priv_key)).to eq("\"\xA5\xA3l\xED\xFC\xC0\x85\x80z\x12\x8D{\xC6\xC28!g\xADl\r\xBC_\xDF\xF8Vt\x0F=\x84\xC0\x99\xAD\x1E\xA8z\x8D\xB0\x96qM\x97\x88\xBDT@G\xC9\x02\x1EB)\xCE'\xE4\xC0\xA6\x92P\xAD\xFC\xFF\xBB\v".b) + end end