From dcb2c554d9dcc026bfe83ed66105a799d4aee62b Mon Sep 17 00:00:00 2001 From: Anton Berezin Date: Thu, 9 Jul 2026 11:17:13 +0200 Subject: [PATCH 1/9] test: SQE::USM SNMPv3 key localization with RFC 3414 vectors --- t/lib/SQE/USM.pm | 32 ++++++++++++++++++++++++++++++++ t/usm.t | 20 ++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 t/lib/SQE/USM.pm create mode 100644 t/usm.t diff --git a/t/lib/SQE/USM.pm b/t/lib/SQE/USM.pm new file mode 100644 index 0000000..29f8c84 --- /dev/null +++ b/t/lib/SQE/USM.pm @@ -0,0 +1,32 @@ +package SQE::USM; +# ABOUTME: Independent SNMPv3 USM crypto (key localization, HMAC, AES-CFB) for +# ABOUTME: the fake agent — mirrors RFC 3414/3826 and cross-checks SQE's C code. +use strict; +use warnings; +use Digest::SHA qw(sha1 sha224 sha256 sha384 sha512); + +my %DIGEST = ( + sha1 => \&sha1, sha224 => \&sha224, sha256 => \&sha256, + sha384 => \&sha384, sha512 => \&sha512, +); + +sub password_to_key { + my ($algo, $pass) = @_; + my $d = $DIGEST{$algo} or die "unknown auth proto $algo"; + die "empty password" unless length $pass; + my $reps = int(1048576 / length($pass)) + 1; + return $d->(substr($pass x $reps, 0, 1048576)); +} + +sub localize_key { + my ($algo, $ku, $engine_id) = @_; + my $d = $DIGEST{$algo} or die "unknown auth proto $algo"; + return $d->($ku . $engine_id . $ku); +} + +sub password_to_kul { + my ($algo, $pass, $engine_id) = @_; + return localize_key($algo, password_to_key($algo, $pass), $engine_id); +} + +1; diff --git a/t/usm.t b/t/usm.t new file mode 100644 index 0000000..f5eb32a --- /dev/null +++ b/t/usm.t @@ -0,0 +1,20 @@ +#! /usr/bin/perl +# ABOUTME: Unit tests for SQE::USM — SNMPv3 key localization, HMAC, and AES-CFB +# ABOUTME: checked against RFC 3414 known-answer vectors and round-trips. +use strict; +use warnings; +use FindBin; +use lib "$FindBin::Bin/lib"; +use Test2::V0; +use SQE::USM; + +# RFC 3414 Appendix A.3.1 SHA-1 vector, password "maplesyrup". +my $eid = pack('H*', '000000000000000000000002'); +is(unpack('H*', SQE::USM::password_to_key('sha1', 'maplesyrup')), + '9fb5cc0381497b3793528939ff788d5d79145211', 'SHA1 Ku matches RFC 3414 A.3.1'); +is(unpack('H*', SQE::USM::localize_key('sha1', SQE::USM::password_to_key('sha1', 'maplesyrup'), $eid)), + '6695febc9288e36282235fc7151f128497b38f3f', 'SHA1 Kul matches RFC 3414 A.3.1'); +is(unpack('H*', SQE::USM::password_to_kul('sha1', 'maplesyrup', $eid)), + '6695febc9288e36282235fc7151f128497b38f3f', 'password_to_kul composes the two'); + +done_testing; From 23606923fef035186f23eaf7681f9a2784ac0336 Mon Sep 17 00:00:00 2001 From: Anton Berezin Date: Thu, 9 Jul 2026 11:21:36 +0200 Subject: [PATCH 2/9] style: use tabs in SQE::USM and t/usm.t to match repo convention --- t/lib/SQE/USM.pm | 24 ++++++++++++------------ t/usm.t | 6 +++--- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/t/lib/SQE/USM.pm b/t/lib/SQE/USM.pm index 29f8c84..26f4b06 100644 --- a/t/lib/SQE/USM.pm +++ b/t/lib/SQE/USM.pm @@ -6,27 +6,27 @@ use warnings; use Digest::SHA qw(sha1 sha224 sha256 sha384 sha512); my %DIGEST = ( - sha1 => \&sha1, sha224 => \&sha224, sha256 => \&sha256, - sha384 => \&sha384, sha512 => \&sha512, + sha1 => \&sha1, sha224 => \&sha224, sha256 => \&sha256, + sha384 => \&sha384, sha512 => \&sha512, ); sub password_to_key { - my ($algo, $pass) = @_; - my $d = $DIGEST{$algo} or die "unknown auth proto $algo"; - die "empty password" unless length $pass; - my $reps = int(1048576 / length($pass)) + 1; - return $d->(substr($pass x $reps, 0, 1048576)); + my ($algo, $pass) = @_; + my $d = $DIGEST{$algo} or die "unknown auth proto $algo"; + die "empty password" unless length $pass; + my $reps = int(1048576 / length($pass)) + 1; + return $d->(substr($pass x $reps, 0, 1048576)); } sub localize_key { - my ($algo, $ku, $engine_id) = @_; - my $d = $DIGEST{$algo} or die "unknown auth proto $algo"; - return $d->($ku . $engine_id . $ku); + my ($algo, $ku, $engine_id) = @_; + my $d = $DIGEST{$algo} or die "unknown auth proto $algo"; + return $d->($ku . $engine_id . $ku); } sub password_to_kul { - my ($algo, $pass, $engine_id) = @_; - return localize_key($algo, password_to_key($algo, $pass), $engine_id); + my ($algo, $pass, $engine_id) = @_; + return localize_key($algo, password_to_key($algo, $pass), $engine_id); } 1; diff --git a/t/usm.t b/t/usm.t index f5eb32a..495b6b7 100644 --- a/t/usm.t +++ b/t/usm.t @@ -11,10 +11,10 @@ use SQE::USM; # RFC 3414 Appendix A.3.1 SHA-1 vector, password "maplesyrup". my $eid = pack('H*', '000000000000000000000002'); is(unpack('H*', SQE::USM::password_to_key('sha1', 'maplesyrup')), - '9fb5cc0381497b3793528939ff788d5d79145211', 'SHA1 Ku matches RFC 3414 A.3.1'); + '9fb5cc0381497b3793528939ff788d5d79145211', 'SHA1 Ku matches RFC 3414 A.3.1'); is(unpack('H*', SQE::USM::localize_key('sha1', SQE::USM::password_to_key('sha1', 'maplesyrup'), $eid)), - '6695febc9288e36282235fc7151f128497b38f3f', 'SHA1 Kul matches RFC 3414 A.3.1'); + '6695febc9288e36282235fc7151f128497b38f3f', 'SHA1 Kul matches RFC 3414 A.3.1'); is(unpack('H*', SQE::USM::password_to_kul('sha1', 'maplesyrup', $eid)), - '6695febc9288e36282235fc7151f128497b38f3f', 'password_to_kul composes the two'); + '6695febc9288e36282235fc7151f128497b38f3f', 'password_to_kul composes the two'); done_testing; From a7ba9fab0cb6a253c4d785a53c351a461abd8266 Mon Sep 17 00:00:00 2001 From: Anton Berezin Date: Thu, 9 Jul 2026 11:24:07 +0200 Subject: [PATCH 3/9] test: SQE::USM HMAC truncation and AES-128-CFB round-trip --- t/lib/SQE/USM.pm | 42 +++++++++++++++++++++++++++++++++++++++++- t/usm.t | 17 +++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/t/lib/SQE/USM.pm b/t/lib/SQE/USM.pm index 26f4b06..c1a670d 100644 --- a/t/lib/SQE/USM.pm +++ b/t/lib/SQE/USM.pm @@ -3,13 +3,21 @@ package SQE::USM; # ABOUTME: the fake agent — mirrors RFC 3414/3826 and cross-checks SQE's C code. use strict; use warnings; -use Digest::SHA qw(sha1 sha224 sha256 sha384 sha512); +use Digest::SHA qw(sha1 sha224 sha256 sha384 sha512 + hmac_sha1 hmac_sha224 hmac_sha256 hmac_sha384 hmac_sha512); +use Crypt::Rijndael; my %DIGEST = ( sha1 => \&sha1, sha224 => \&sha224, sha256 => \&sha256, sha384 => \&sha384, sha512 => \&sha512, ); +my %HMAC = ( + sha1 => \&hmac_sha1, sha224 => \&hmac_sha224, sha256 => \&hmac_sha256, + sha384 => \&hmac_sha384, sha512 => \&hmac_sha512, +); +my %MACLEN = (sha1 => 12, sha224 => 16, sha256 => 24, sha384 => 32, sha512 => 48); + sub password_to_key { my ($algo, $pass) = @_; my $d = $DIGEST{$algo} or die "unknown auth proto $algo"; @@ -29,4 +37,36 @@ sub password_to_kul { return localize_key($algo, password_to_key($algo, $pass), $engine_id); } +sub maclen { $MACLEN{$_[0]} // die "unknown auth proto $_[0]" } + +sub hmac { + my ($algo, $kul, $msg) = @_; + my $h = $HMAC{$algo} or die "unknown auth proto $algo"; + return substr($h->($msg, $kul), 0, $MACLEN{$algo}); # Digest::SHA hmac: key is the LAST arg +} + +sub priv_key { + my ($auth_algo, $priv_pass, $engine_id) = @_; + return substr(password_to_kul($auth_algo, $priv_pass, $engine_id), 0, 16); +} + +# AES-CFB128. $mode 'e'/'d'. IV = boots(N) . time(N) . salt(8). Feedback register +# is always the ciphertext block (output when encrypting, input when decrypting). +sub aes_cfb { + my ($mode, $key, $boots, $time, $salt, $data) = @_; + my $iv = pack('N', $boots) . pack('N', $time) . $salt; + my $c = Crypt::Rijndael->new($key, Crypt::Rijndael::MODE_ECB()); + my ($out, $fb) = ('', $iv); + for (my $i = 0; $i < length $data; $i += 16) { + my $ks = $c->encrypt($fb); + my $in = substr($data, $i, 16); + my $r = length $in; + my $ob = $in ^ substr($ks, 0, $r); + $out .= $ob; + my $cipher = $mode eq 'e' ? $ob : $in; + $fb = substr($cipher . substr($ks, $r), 0, 16); + } + return $out; +} + 1; diff --git a/t/usm.t b/t/usm.t index 495b6b7..2ce1507 100644 --- a/t/usm.t +++ b/t/usm.t @@ -17,4 +17,21 @@ is(unpack('H*', SQE::USM::localize_key('sha1', SQE::USM::password_to_key('sha1', is(unpack('H*', SQE::USM::password_to_kul('sha1', 'maplesyrup', $eid)), '6695febc9288e36282235fc7151f128497b38f3f', 'password_to_kul composes the two'); +# MAC truncation lengths per protocol +is(SQE::USM::maclen('sha1'), 12, 'sha1 maclen'); +is(SQE::USM::maclen('sha256'), 24, 'sha256 maclen'); +is(SQE::USM::maclen('sha512'), 48, 'sha512 maclen'); +is(length SQE::USM::hmac('sha256', 'x' x 24, 'hello'), 24, 'hmac truncated to maclen'); + +# priv key is 16 bytes (AES-128), derived with the auth algorithm +is(length SQE::USM::priv_key('sha256', 'sqeprivpass12', $eid), 16, 'priv key is 16 bytes'); + +# AES-CFB128 round-trips for a non-block-aligned payload +my $key = SQE::USM::priv_key('sha256', 'sqeprivpass12', $eid); +my $salt = pack('H*', '1122334455667788'); +my $pt = 'scopedPDU bytes not aligned to 16 !!'; +my $ct = SQE::USM::aes_cfb('e', $key, 12, 3456, $salt, $pt); +isnt($ct, $pt, 'ciphertext differs from plaintext'); +is(SQE::USM::aes_cfb('d', $key, 12, 3456, $salt, $ct), $pt, 'AES-128-CFB round-trip'); + done_testing; From b39c06bc754d6444dc9134bc2c1c902066c5da92 Mon Sep 17 00:00:00 2001 From: Anton Berezin Date: Thu, 9 Jul 2026 11:30:40 +0200 Subject: [PATCH 4/9] refactor: extract shared PDU-processing core in FakeAgent --- t/lib/SQE/FakeAgent.pm | 68 +++++++++++++++++++++++------------------- 1 file changed, 38 insertions(+), 30 deletions(-) diff --git a/t/lib/SQE/FakeAgent.pm b/t/lib/SQE/FakeAgent.pm index f4c2cc5..8b23bb4 100644 --- a/t/lib/SQE/FakeAgent.pm +++ b/t/lib/SQE/FakeAgent.pm @@ -187,34 +187,9 @@ sub _next_entry { return undef; } -sub _handle { - my ($self, $pkt) = @_; - my ($tag, $msg) = _get_tlv($pkt, 0); - die "not a sequence\n" unless $tag == 0x30; - my $pos = 0; - (my $t, my $ver_c, $pos) = _get_tlv($msg, $pos); - die "bad version\n" unless $t == 0x02; - my $version = _dec_uint($ver_c); # 0 = v1, 1 = v2c - ($t, my $community, $pos) = _get_tlv($msg, $pos); - die "bad community\n" unless $t == 0x04; - return undef unless $community eq $self->{community}; - (my $pdu_tag, my $pdu, $pos) = _get_tlv($msg, $pos); - - $pos = 0; - ($t, my $reqid_c, $pos) = _get_tlv($pdu, $pos); - (undef, my $f1_c, $pos) = _get_tlv($pdu, $pos); # error-status | non-repeaters - (undef, my $f2_c, $pos) = _get_tlv($pdu, $pos); # error-index | max-repetitions - ($t, my $vbl, $pos) = _get_tlv($pdu, $pos); - die "bad varbind list\n" unless $t == 0x30; - my @oids; - my $vpos = 0; - while ($vpos < length $vbl) { - (my $vt, my $vb, $vpos) = _get_tlv($vbl, $vpos); - my ($ot, $oid_c) = _get_tlv($vb, 0); - die "bad varbind\n" unless $vt == 0x30 && $ot == 0x06; - push @oids, _dec_oid($oid_c); - } - +sub _process_pdu { + my ($self, $version, $pdu_tag, $f1_c, $f2_c, $oids) = @_; + my @oids = @$oids; my ($errst, $erridx, @out) = (0, 0); if ($pdu_tag == 0xa0) { # GET if ($version == 0) { # v1: all-or-nothing @@ -229,7 +204,7 @@ sub _handle { } else { @out = map { $self->_find($_) } @oids; } - } else { # v2c: per-varbind exceptions + } else { # v2c/v3: per-varbind exceptions @out = map { $self->_find($_) // [$_, 0x80, ''] } @oids; # noSuchObject } } elsif ($pdu_tag == 0xa1) { # GETNEXT @@ -248,7 +223,7 @@ sub _handle { } else { @out = map { $self->_next_entry($_) // [$_, 0x82, ''] } @oids; # endOfMibView } - } elsif ($pdu_tag == 0xa5) { # GETBULK (v2c) + } elsif ($pdu_tag == 0xa5) { # GETBULK (v2c/v3) my $nonrep = _dec_uint($f1_c); my $maxrep = _dec_uint($f2_c); my @cursors = @oids; @@ -275,6 +250,39 @@ sub _handle { } else { die "unsupported pdu $pdu_tag\n"; } + return ($errst, $erridx, \@out); +} + +sub _handle { + my ($self, $pkt) = @_; + my ($tag, $msg) = _get_tlv($pkt, 0); + die "not a sequence\n" unless $tag == 0x30; + my $pos = 0; + (my $t, my $ver_c, $pos) = _get_tlv($msg, $pos); + die "bad version\n" unless $t == 0x02; + my $version = _dec_uint($ver_c); # 0 = v1, 1 = v2c + ($t, my $community, $pos) = _get_tlv($msg, $pos); + die "bad community\n" unless $t == 0x04; + return undef unless $community eq $self->{community}; + (my $pdu_tag, my $pdu, $pos) = _get_tlv($msg, $pos); + + $pos = 0; + ($t, my $reqid_c, $pos) = _get_tlv($pdu, $pos); + (undef, my $f1_c, $pos) = _get_tlv($pdu, $pos); # error-status | non-repeaters + (undef, my $f2_c, $pos) = _get_tlv($pdu, $pos); # error-index | max-repetitions + ($t, my $vbl, $pos) = _get_tlv($pdu, $pos); + die "bad varbind list\n" unless $t == 0x30; + my @oids; + my $vpos = 0; + while ($vpos < length $vbl) { + (my $vt, my $vb, $vpos) = _get_tlv($vbl, $vpos); + my ($ot, $oid_c) = _get_tlv($vb, 0); + die "bad varbind\n" unless $vt == 0x30 && $ot == 0x06; + push @oids, _dec_oid($oid_c); + } + + my ($errst, $erridx, $out) = $self->_process_pdu($version, $pdu_tag, $f1_c, $f2_c, \@oids); + my @out = @$out; @out = grep { $_->[0] ne $self->{omit_oid} } @out if defined $self->{omit_oid}; From 07629ae592e8a2ecbf8069758f364e47d060096f Mon Sep 17 00:00:00 2001 From: Anton Berezin Date: Thu, 9 Jul 2026 11:39:43 +0200 Subject: [PATCH 5/9] test: SNMPv3 authPriv end-to-end via fake agent USM support --- t/behavior-v3.t | 43 ++++++++ t/lib/SQE/FakeAgent.pm | 229 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 272 insertions(+) create mode 100644 t/behavior-v3.t diff --git a/t/behavior-v3.t b/t/behavior-v3.t new file mode 100644 index 0000000..311049f --- /dev/null +++ b/t/behavior-v3.t @@ -0,0 +1,43 @@ +#! /usr/bin/perl +# ABOUTME: End-to-end SNMPv3/USM tests: discovery, time-sync, authPriv GET, and +# ABOUTME: deterministic v3 misbehavior, driven against the scriptable fake agent. +use strict; +use warnings; +use FindBin; +use lib "$FindBin::Bin/lib"; +use Test2::V0; +use SQE::Test ':all'; +use SQE::FakeAgent; + +my $hostname = 'sqe-fake-v3'; +my @tree = ( + ['1.3.6.1.2.1.1.5.0', str => $hostname], + ['1.3.6.1.2.1.1.3.0', ticks => 987654], +); +my %v3 = ( + engine_id => '80001f88047371656369', + username => 'sqetest', + auth_proto => 'sha256', auth_pass => 'sqeauthpass12', + priv_proto => 'aes128', priv_pass => 'sqeprivpass12', + boots => 12, time => 3456, +); + +my $d = spawn_daemon(); +my $agent = SQE::FakeAgent->spawn(tree => \@tree, v3 => \%v3); +my $target = '127.0.0.1'; +my $port = $agent->port; + +request_match($d, 'set v3 credentials', + [RT_SETOPT, 200, $target, $port, { + version => 3, engineid => $v3{engine_id}, username => $v3{username}, + authprotocol => 'sha256', authpassword => $v3{auth_pass}, + privprotocol => 'aes128', privpassword => $v3{priv_pass}, + }], + [RT_SETOPT|RT_REPLY, 200, T()]); + +request_match($d, 'v3 authPriv get sysName', + [RT_GET, 201, $target, $port, ['1.3.6.1.2.1.1.5.0']], + [RT_GET|RT_REPLY, 201, [['1.3.6.1.2.1.1.5.0', $hostname]]]); + +$d->stop; +done_testing; diff --git a/t/lib/SQE/FakeAgent.pm b/t/lib/SQE/FakeAgent.pm index 8b23bb4..d0c0a3c 100644 --- a/t/lib/SQE/FakeAgent.pm +++ b/t/lib/SQE/FakeAgent.pm @@ -5,6 +5,7 @@ use strict; use warnings; use IO::Socket::INET; use POSIX (); +use SQE::USM; sub _oid_cmp { my @a = split /\./, $_[0]; @@ -27,7 +28,24 @@ sub spawn { omit_oid => $opt{omit_oid}, malformed => $opt{malformed} // '', delay_ms => $opt{delay_ms} // 0, + v3 => $opt{v3}, + v3_never_sync => $opt{v3_never_sync} // 0, + v3_report => $opt{v3_report} // '', + v3_reply_fault => $opt{v3_reply_fault} // '', }, $class; + if (my $v = $self->{v3}) { + my $eid = pack('H*', $v->{engine_id}); + $self->{v3set} = { + eid => $eid, + username => $v->{username}, + auth_proto => $v->{auth_proto}, + auth_kul => SQE::USM::password_to_kul($v->{auth_proto}, $v->{auth_pass}, $eid), + priv_key => SQE::USM::priv_key($v->{auth_proto}, $v->{priv_pass}, $eid), + maclen => SQE::USM::maclen($v->{auth_proto}), + boots => $v->{boots} // 1, + time => $v->{time} // 0, + }; + } pipe(my $rd, my $wr) or die "pipe: $!"; my $pid = fork() // die "fork: $!"; if (!$pid) { @@ -72,6 +90,7 @@ sub _serve { $n++; next if $self->{drop_all}; next if $n <= $self->{drop_first}; + $self->{_last_pkt} = $pkt; my $reply = eval { $self->_handle($pkt) }; next unless defined $reply; if ($self->{malformed} eq 'truncate') { @@ -261,6 +280,7 @@ sub _handle { (my $t, my $ver_c, $pos) = _get_tlv($msg, $pos); die "bad version\n" unless $t == 0x02; my $version = _dec_uint($ver_c); # 0 = v1, 1 = v2c + return $self->_handle_v3($msg, $pos) if $version == 3; ($t, my $community, $pos) = _get_tlv($msg, $pos); die "bad community\n" unless $t == 0x04; return undef unless $community eq $self->{community}; @@ -298,4 +318,213 @@ sub _handle { return _tlv(0x30, _enc_uint(0x02, $version) . _tlv(0x04, $community) . $resp); } +# ---- SNMPv3 / USM handling ---- + +# usmStats OIDs (value is a Counter32) +my %USM_STATS = ( + not_in_time => '1.3.6.1.6.3.15.1.1.2.0', + wrong_digests => '1.3.6.1.6.3.15.1.1.5.0', + unknown_user => '1.3.6.1.6.3.15.1.1.3.0', + unknown_engine => '1.3.6.1.6.3.15.1.1.4.0', +); + +sub _rand_salt { join '', map { chr int rand 256 } 1 .. 8 } + +# Parse the v3 envelope. Returns a hashref with the decoded fields plus the byte +# offset/length of msgAuthenticationParameters within $pkt (for HMAC checking). +sub _parse_v3 { + my ($self, $pkt) = @_; + my ($tag, $body, undef) = _get_tlv($pkt, 0); # outer SEQUENCE + my $base = 2; # header of outer SEQUENCE is 1-byte tag + len; + # locate body offset within $pkt precisely: + $base = length($pkt) - length($body); + my $pos = 0; + (undef, my $ver_c, $pos) = _get_tlv($body, $pos); # version (already known == 3) + (undef, my $gdata, $pos) = _get_tlv($body, $pos); # msgGlobalData SEQUENCE + my ($sp_tag, $sp, $sp_end) = _get_tlv($body, $pos); # msgSecurityParams OCTET STRING + my $sp_body_off = $base + ($sp_end - length($sp)); # offset of $sp within $pkt + $pos = $sp_end; + (my $spd_tag, my $spd, undef) = _get_tlv($body, $pos);# scopedPduData (SEQ or OCTETSTR) + + # msgGlobalData: msgID, msgMaxSize, msgFlags, msgSecurityModel + my $gp = 0; + (undef, my $mid_c, $gp) = _get_tlv($gdata, $gp); + (undef, undef, $gp) = _get_tlv($gdata, $gp); # msgMaxSize + (undef, my $flags_c, $gp) = _get_tlv($gdata, $gp); + + # USM security params sequence + my ($usm_tag, $usm) = _get_tlv($sp, 0); + my $up = 0; + (undef, my $eid, $up) = _get_tlv($usm, $up); + (undef, my $bo_c, $up) = _get_tlv($usm, $up); + (undef, my $ti_c, $up) = _get_tlv($usm, $up); + (undef, my $user, $up) = _get_tlv($usm, $up); + my $auth_val_off_in_usm = $up + 2; # skip authParams tag+len (maclen<128 => 1-byte len) + (undef, my $authp, $up) = _get_tlv($usm, $up); + (undef, my $privp, $up) = _get_tlv($usm, $up); + + # absolute offset of authParams value inside $pkt: + my $usm_hdr = length($sp) - length($usm); # OCTETSTR-value contains SEQUENCE header + my $auth_abs = $sp_body_off + $usm_hdr + $auth_val_off_in_usm; + + return { + mid => _dec_uint($mid_c), + flags => ord($flags_c), + eid => $eid, + boots => _dec_uint($bo_c), + time => _dec_uint($ti_c), + user => $user, + authp => $authp, + privp => $privp, + auth_abs => $auth_abs, + spd_tag => $spd_tag, + spd => $spd, + }; +} + +# Build an OCTET STRING TLV +sub _octet { _tlv(0x04, $_[0]) } + +# Build a full v3 message. %a keys: mid, flags, boots, time, authenticated (bool), +# scoped (raw scopedPDU bytes, already encrypted or plaintext), encrypted (bool), +# privp (8-byte salt or ''), eid, username, auth_kul, auth_proto. +sub _build_v3 { + my ($self, %a) = @_; + my $maclen = $a{authenticated} ? SQE::USM::maclen($a{auth_proto}) : 0; + + my $usm_pre = _octet($a{eid}) . _enc_uint(0x02, $a{boots}) . _enc_uint(0x02, $a{time}) + . _octet($a{username}); + my $authtlv = _octet("\x00" x $maclen); + my $auth_hdr = length($authtlv) - $maclen; # tag+len bytes + my $privtlv = _octet($a{privp} // ''); + my $usm_body = $usm_pre . $authtlv . $privtlv; + my $auth_off = length($usm_pre) + $auth_hdr; # within $usm_body + + my $usm_seq = _tlv(0x30, $usm_body); + $auth_off += length($usm_seq) - length($usm_body); # + SEQUENCE header + my $secparams= _octet($usm_seq); + $auth_off += length($secparams) - length($usm_seq); # + OCTETSTR header + + my $gdata = _tlv(0x30, + _enc_uint(0x02, $a{mid}) . _enc_uint(0x02, 65535) + . _octet(chr $a{flags}) . _enc_uint(0x02, 3)); + my $ver = _enc_uint(0x02, 3); + my $spd = $a{encrypted} ? _octet($a{scoped}) : $a{scoped}; + + my $prefix = $ver . $gdata; + $auth_off += length($prefix); # secparams follows ver+gdata + my $body = $prefix . $secparams . $spd; + my $msg = _tlv(0x30, $body); + $auth_off += length($msg) - length($body); # + outer SEQUENCE header + + if ($a{authenticated}) { + my $mac = SQE::USM::hmac($a{auth_proto}, $a{auth_kul}, $msg); + substr($msg, $auth_off, $maclen) = $mac; + } + return $msg; +} + +# Build a scopedPDU SEQUENCE: ctxEngineID, ctxName(""), inner PDU bytes. +sub _scoped { my ($self, $eid, $pdu) = @_; return _tlv(0x30, _octet($eid) . _octet('') . $pdu) } + +# Build a REPORT PDU carrying one usmStats varbind (Counter32 = 1). +sub _report_pdu { + my ($self, $mid, $oid) = @_; + my $vb = _tlv(0x30, _tlv(0x06, _enc_oid_content($oid)) . _enc_uint(0x41, 1)); + return _tlv(0xa8, + _enc_uint(0x02, $mid) . _enc_uint(0x02, 0) . _enc_uint(0x02, 0) . _tlv(0x30, $vb)); +} + +sub _handle_v3 { + my ($self, $msg, undef) = @_; + my $s = $self->{v3set} or die "v3 packet but agent not configured for v3\n"; + return undef if $self->{v3_never_sync}; + + my $r = $self->_parse_v3($self->{_last_pkt}); + + # username must match, else send unknown-user report (noAuth) + if ($r->{user} ne $s->{username}) { + return $self->_v3_report($r, 'unknown_user'); + } + + # request-side auth verification: zero the auth field, recompute, compare + my $pkt = $self->{_last_pkt}; + my $got = substr($pkt, $r->{auth_abs}, $s->{maclen}); + substr($pkt, $r->{auth_abs}, $s->{maclen}) = "\x00" x $s->{maclen}; + my $calc = SQE::USM::hmac($s->{auth_proto}, $s->{auth_kul}, $pkt); + return $self->_v3_report($r, 'wrong_digests') if $calc ne $got; + + # forced-report knob (e.g. always answer with a chosen report type) + return $self->_v3_report($r, $self->{v3_report}) if $self->{v3_report}; + + # time-window check: out of window -> notInTimeWindows report + if ($r->{boots} != $s->{boots} || abs($r->{time} - $s->{time}) > 150) { + return $self->_v3_report($r, 'not_in_time'); + } + + # in window: decrypt, process, reply + my $scoped = $r->{spd}; + if ($r->{flags} & 0x02) { # ENCRYPTED + $scoped = SQE::USM::aes_cfb('d', $s->{priv_key}, $r->{boots}, $r->{time}, $r->{privp}, $r->{spd}); + } + # scoped is now a SEQUENCE { ctxEngineID, ctxName, PDU } + my (undef, $seq_body) = _get_tlv($scoped, 0); + my $bp = 0; + (undef, undef, $bp) = _get_tlv($seq_body, $bp); # ctxEngineID + (undef, undef, $bp) = _get_tlv($seq_body, $bp); # ctxName + (my $pdu_tag, my $pdu, $bp) = _get_tlv($seq_body, $bp); + + my $pp = 0; + (undef, my $reqid_c, $pp) = _get_tlv($pdu, $pp); + (undef, my $f1_c, $pp) = _get_tlv($pdu, $pp); + (undef, my $f2_c, $pp) = _get_tlv($pdu, $pp); + (undef, my $vbl, $pp) = _get_tlv($pdu, $pp); + my @oids; + my $vp = 0; + while ($vp < length $vbl) { + (undef, my $vb, $vp) = _get_tlv($vbl, $vp); + my (undef, $oid_c) = _get_tlv($vb, 0); # varbind = SEQ { OID, value } + push @oids, _dec_oid($oid_c); + } + + my ($errst, $erridx, $out) = $self->_process_pdu(1, $pdu_tag, $f1_c, $f2_c, \@oids); + @$out = grep { $_->[0] ne $self->{omit_oid} } @$out if defined $self->{omit_oid}; + + my $vbl_out = join '', map { + _tlv(0x30, _tlv(0x06, _enc_oid_content($_->[0])) . _enc_value($_->[1], $_->[2])) + } @$out; + my $resp_pdu = _tlv(0xa2, + _tlv(0x02, $reqid_c) . _enc_uint(0x02, $errst) . _enc_uint(0x02, $erridx) . _tlv(0x30, $vbl_out)); + + my $eid = ($self->{v3_reply_fault} eq 'engine_id') ? ($s->{eid} . "\x01") : $s->{eid}; + my $user= ($self->{v3_reply_fault} eq 'username') ? ($s->{username} . 'X') : $s->{username}; + + my $scoped_plain = $self->_scoped($eid, $resp_pdu); + my $salt = _rand_salt(); + my $enc = SQE::USM::aes_cfb('e', $s->{priv_key}, $s->{boots}, $s->{time}, $salt, $scoped_plain); + + my $reply = $self->_build_v3( + mid => $r->{mid}, flags => 0x03, boots => $s->{boots}, time => $s->{time}, + authenticated => 1, encrypted => 1, scoped => $enc, privp => $salt, + eid => $eid, username => $user, auth_kul => $s->{auth_kul}, auth_proto => $s->{auth_proto}, + ); + if ($self->{v3_reply_fault} eq 'bad_hmac') { + substr($reply, -1) = chr((ord(substr $reply, -1) ^ 0xff)); # corrupt last byte (inside MAC region unlikely; see note) + } + return $reply; +} + +# Build a noAuthNoPriv report carrying $type; syncs the agent's boots/time. +sub _v3_report { + my ($self, $r, $type) = @_; + my $s = $self->{v3set}; + my $oid = $USM_STATS{$type} or die "unknown report type $type\n"; + my $scoped = $self->_scoped($s->{eid}, $self->_report_pdu($r->{mid}, $oid)); + return $self->_build_v3( + mid => $r->{mid}, flags => 0x00, boots => $s->{boots}, time => $s->{time}, + authenticated => 0, encrypted => 0, scoped => $scoped, privp => '', + eid => $s->{eid}, username => $s->{username}, + ); +} + 1; From 53597963d996428ba6ce677e0a9568a9c5e711dc Mon Sep 17 00:00:00 2001 From: Anton Berezin Date: Thu, 9 Jul 2026 11:44:50 +0200 Subject: [PATCH 6/9] test: v3 time-sync and wrong-digests misbehavior scenarios --- t/behavior-v3.t | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/t/behavior-v3.t b/t/behavior-v3.t index 311049f..7285504 100644 --- a/t/behavior-v3.t +++ b/t/behavior-v3.t @@ -39,5 +39,38 @@ request_match($d, 'v3 authPriv get sysName', [RT_GET, 201, $target, $port, ['1.3.6.1.2.1.1.5.0']], [RT_GET|RT_REPLY, 201, [['1.3.6.1.2.1.1.5.0', $hostname]]]); +# never_sync: the agent ignores discovery, so the GET times out. +{ + my $a2 = SQE::FakeAgent->spawn(tree => \@tree, v3 => \%v3, v3_never_sync => 1); + request_match($d, 'v3 set creds (never_sync agent)', + [RT_SETOPT, 300, $target, $a2->port, { + version => 3, engineid => $v3{engine_id}, username => $v3{username}, + authprotocol => 'sha256', authpassword => $v3{auth_pass}, + privprotocol => 'aes128', privpassword => $v3{priv_pass}, + timeout => 100, retries => 1 }], + [RT_SETOPT|RT_REPLY, 300, T()]); + request_match($d, 'v3 get times out when agent never syncs', + [RT_GET, 301, $target, $a2->port, ['1.3.6.1.2.1.1.5.0']], + [RT_GET|RT_REPLY, 301, [['1.3.6.1.2.1.1.5.0', ['timeout']]]]); +} + +# wrong-password: agent replies usmStatsWrongDigests -> SQE drops, bad_snmp_responses climbs. +{ + my $a3 = SQE::FakeAgent->spawn(tree => \@tree, v3 => \%v3); + my $before = $d->request([RT_INFO, 310])->[2]{global}{bad_snmp_responses}; + request_match($d, 'v3 set WRONG password', + [RT_SETOPT, 311, $target, $a3->port, { + version => 3, engineid => $v3{engine_id}, username => $v3{username}, + authprotocol => 'sha256', authpassword => 'WRONGwrongWRONG', + privprotocol => 'aes128', privpassword => $v3{priv_pass}, + timeout => 100, retries => 1 }], + [RT_SETOPT|RT_REPLY, 311, T()]); + request_match($d, 'v3 get with wrong password fails', + [RT_GET, 312, $target, $a3->port, ['1.3.6.1.2.1.1.5.0']], + [RT_GET|RT_REPLY, 312, [['1.3.6.1.2.1.1.5.0', ['timeout']]]]); + my $after = $d->request([RT_INFO, 313])->[2]{global}{bad_snmp_responses}; + ok($after > $before, 'wrong-digests report bumped bad_snmp_responses'); +} + $d->stop; done_testing; From c7d26f4d2308e0802794c0f9ae07addcf933b86e Mon Sep 17 00:00:00 2001 From: Anton Berezin Date: Thu, 9 Jul 2026 11:50:42 +0200 Subject: [PATCH 7/9] test: v3 reply-integrity faults (bad HMAC, engine-id, username) --- t/behavior-v3.t | 21 +++++++++++++++++++++ t/lib/SQE/FakeAgent.pm | 9 +++++---- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/t/behavior-v3.t b/t/behavior-v3.t index 7285504..2c0f034 100644 --- a/t/behavior-v3.t +++ b/t/behavior-v3.t @@ -72,5 +72,26 @@ request_match($d, 'v3 authPriv get sysName', ok($after > $before, 'wrong-digests report bumped bad_snmp_responses'); } +# reply-integrity faults: SQE must reject a well-formed but tampered reply. +my @faults = qw(bad_hmac engine_id username); +for my $i (0 .. $#faults) { + my $fault = $faults[$i]; + my $base = 400 + 10 * $i; + my $af = SQE::FakeAgent->spawn(tree => \@tree, v3 => \%v3, v3_reply_fault => $fault); + request_match($d, "v3 set creds ($fault agent)", + [RT_SETOPT, $base, $target, $af->port, { + version => 3, engineid => $v3{engine_id}, username => $v3{username}, + authprotocol => 'sha256', authpassword => $v3{auth_pass}, + privprotocol => 'aes128', privpassword => $v3{priv_pass}, + timeout => 100, retries => 1 }], + [RT_SETOPT|RT_REPLY, $base, T()]); + my $before = $d->request([RT_INFO, $base + 1])->[2]{global}{bad_snmp_responses}; + request_match($d, "v3 get fails under $fault", + [RT_GET, $base + 2, $target, $af->port, ['1.3.6.1.2.1.1.5.0']], + [RT_GET|RT_REPLY, $base + 2, [['1.3.6.1.2.1.1.5.0', ['timeout']]]]); + my $after = $d->request([RT_INFO, $base + 3])->[2]{global}{bad_snmp_responses}; + ok($after > $before, "$fault reply bumped bad_snmp_responses"); +} + $d->stop; done_testing; diff --git a/t/lib/SQE/FakeAgent.pm b/t/lib/SQE/FakeAgent.pm index d0c0a3c..aef849f 100644 --- a/t/lib/SQE/FakeAgent.pm +++ b/t/lib/SQE/FakeAgent.pm @@ -503,14 +503,15 @@ sub _handle_v3 { my $salt = _rand_salt(); my $enc = SQE::USM::aes_cfb('e', $s->{priv_key}, $s->{boots}, $s->{time}, $salt, $scoped_plain); + my $auth_kul = $s->{auth_kul}; + $auth_kul = ($auth_kul ^ ("\x01" . "\x00" x (length($auth_kul) - 1))) + if $self->{v3_reply_fault} eq 'bad_hmac'; + my $reply = $self->_build_v3( mid => $r->{mid}, flags => 0x03, boots => $s->{boots}, time => $s->{time}, authenticated => 1, encrypted => 1, scoped => $enc, privp => $salt, - eid => $eid, username => $user, auth_kul => $s->{auth_kul}, auth_proto => $s->{auth_proto}, + eid => $eid, username => $user, auth_kul => $auth_kul, auth_proto => $s->{auth_proto}, ); - if ($self->{v3_reply_fault} eq 'bad_hmac') { - substr($reply, -1) = chr((ord(substr $reply, -1) ^ 0xff)); # corrupt last byte (inside MAC region unlikely; see note) - } return $reply; } From 3c0a10f006c50f0bb53eb80fec4195ebda118d94 Mon Sep 17 00:00:00 2001 From: Anton Berezin Date: Thu, 9 Jul 2026 11:53:52 +0200 Subject: [PATCH 8/9] ci: install Crypt::Rijndael for FakeAgent SNMPv3 tests --- .github/workflows/release.yml | 2 +- .github/workflows/test.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dfb2a6a..3822329 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,7 +46,7 @@ jobs: sudo apt-get update sudo apt-get install -y libjudy-dev libssl-dev cpanminus \ $(apt-cache -q0 show libmsgpack-c-dev >/dev/null 2>&1 && echo libmsgpack-c-dev || echo libmsgpack-dev) - sudo cpanm --notest Data::MessagePack Test2::Suite + sudo cpanm --notest Data::MessagePack Test2::Suite Crypt::Rijndael - name: Build with static dependencies run: make LIBS="$STATIC_LIBS" - name: Verify static linking diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9404dc8..09a2c3d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,12 +22,12 @@ jobs: sudo apt-get update sudo apt-get install -y libjudy-dev libssl-dev snmpd snmp cpanminus \ $(apt-cache -q0 show libmsgpack-c-dev >/dev/null 2>&1 && echo libmsgpack-c-dev || echo libmsgpack-dev) - sudo cpanm --notest Data::MessagePack Test2::Suite + sudo cpanm --notest Data::MessagePack Test2::Suite Crypt::Rijndael - name: Install dependencies (macOS) if: runner.os == 'macOS' run: | brew install msgpack judy openssl@3 perl cpanminus - cpanm --notest Data::MessagePack Test2::Suite + cpanm --notest Data::MessagePack Test2::Suite Crypt::Rijndael - name: Build run: make - name: Verify shipped service files (Linux) @@ -79,6 +79,6 @@ jobs: sudo apt-get update sudo apt-get install -y libjudy-dev libssl-dev cpanminus \ $(apt-cache -q0 show libmsgpack-c-dev >/dev/null 2>&1 && echo libmsgpack-c-dev || echo libmsgpack-dev) - sudo cpanm --notest Data::MessagePack Test2::Suite + sudo cpanm --notest Data::MessagePack Test2::Suite Crypt::Rijndael - name: Build and test with ASan+UBSan run: make OPTIMIZE="-O1 -g -fsanitize=address,undefined -fno-omit-frame-pointer" test From a23cfe102b6dbc710b42586d66f38372bfdb8100 Mon Sep 17 00:00:00 2001 From: Anton Berezin Date: Thu, 9 Jul 2026 12:01:40 +0200 Subject: [PATCH 9/9] docs: note Crypt::Rijndael test dependency, tidy fake-agent v3 comments --- README.markdown | 2 +- t/behavior-v3.t | 2 +- t/lib/SQE/FakeAgent.pm | 17 ++++++++--------- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/README.markdown b/README.markdown index f625a52..6e6620e 100644 --- a/README.markdown +++ b/README.markdown @@ -21,7 +21,7 @@ install`, and running under systemd (Linux) or rc.d (FreeBSD). make test runs the C unit tests and the Perl integration tests via `prove`. -The Perl tests need `Data::MessagePack` and `Test2::Suite` from CPAN. +The Perl tests need `Data::MessagePack`, `Test2::Suite`, and `Crypt::Rijndael` from CPAN. No local snmpd is required. To additionally run sanity tests against a real SNMP agent, set `SQE_REAL_SNMPD=1` (see `t/real-snmpd.t` for the `SQE_SNMPD_*` variables that pick the target agent and credentials). diff --git a/t/behavior-v3.t b/t/behavior-v3.t index 2c0f034..6a764f2 100644 --- a/t/behavior-v3.t +++ b/t/behavior-v3.t @@ -39,7 +39,7 @@ request_match($d, 'v3 authPriv get sysName', [RT_GET, 201, $target, $port, ['1.3.6.1.2.1.1.5.0']], [RT_GET|RT_REPLY, 201, [['1.3.6.1.2.1.1.5.0', $hostname]]]); -# never_sync: the agent ignores discovery, so the GET times out. +# never_sync: the agent ignores all v3 requests, so the GET times out. { my $a2 = SQE::FakeAgent->spawn(tree => \@tree, v3 => \%v3, v3_never_sync => 1); request_match($d, 'v3 set creds (never_sync agent)', diff --git a/t/lib/SQE/FakeAgent.pm b/t/lib/SQE/FakeAgent.pm index aef849f..2a8dde5 100644 --- a/t/lib/SQE/FakeAgent.pm +++ b/t/lib/SQE/FakeAgent.pm @@ -1,5 +1,5 @@ package SQE::FakeAgent; -# ABOUTME: Scriptable fake SNMP v1/v2c agent for integration tests: serves a +# ABOUTME: Scriptable fake SNMP v1/v2c/v3 agent for integration tests: serves a # ABOUTME: fixed OID tree over UDP with configurable misbehavior. use strict; use warnings; @@ -334,14 +334,12 @@ sub _rand_salt { join '', map { chr int rand 256 } 1 .. 8 } # offset/length of msgAuthenticationParameters within $pkt (for HMAC checking). sub _parse_v3 { my ($self, $pkt) = @_; - my ($tag, $body, undef) = _get_tlv($pkt, 0); # outer SEQUENCE - my $base = 2; # header of outer SEQUENCE is 1-byte tag + len; - # locate body offset within $pkt precisely: - $base = length($pkt) - length($body); + my (undef, $body, undef) = _get_tlv($pkt, 0); # outer SEQUENCE + my $base = length($pkt) - length($body); # offset of the SEQUENCE body within $pkt my $pos = 0; - (undef, my $ver_c, $pos) = _get_tlv($body, $pos); # version (already known == 3) + (undef, undef, $pos) = _get_tlv($body, $pos); # version (already known == 3) (undef, my $gdata, $pos) = _get_tlv($body, $pos); # msgGlobalData SEQUENCE - my ($sp_tag, $sp, $sp_end) = _get_tlv($body, $pos); # msgSecurityParams OCTET STRING + (undef, my $sp, my $sp_end) = _get_tlv($body, $pos); # msgSecurityParams OCTET STRING my $sp_body_off = $base + ($sp_end - length($sp)); # offset of $sp within $pkt $pos = $sp_end; (my $spd_tag, my $spd, undef) = _get_tlv($body, $pos);# scopedPduData (SEQ or OCTETSTR) @@ -353,7 +351,7 @@ sub _parse_v3 { (undef, my $flags_c, $gp) = _get_tlv($gdata, $gp); # USM security params sequence - my ($usm_tag, $usm) = _get_tlv($sp, 0); + (undef, my $usm) = _get_tlv($sp, 0); my $up = 0; (undef, my $eid, $up) = _get_tlv($usm, $up); (undef, my $bo_c, $up) = _get_tlv($usm, $up); @@ -515,7 +513,8 @@ sub _handle_v3 { return $reply; } -# Build a noAuthNoPriv report carrying $type; syncs the agent's boots/time. +# Build a noAuthNoPriv report carrying $type; carries the agent's boots/time so +# the client can sync to them. sub _v3_report { my ($self, $r, $type) = @_; my $s = $self->{v3set};