Skip to content

fix: draw a new salt for every encrypted SNMPv3 message - #59

Merged
ilyam8 merged 3 commits into
masterfrom
salt-per-message
Oct 14, 2026
Merged

ilyam8 merged 3 commits into
masterfrom
salt-per-message

Conversation

@ilyam8

@ilyam8 ilyam8 commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

Summary

First step of the bug-fix phase: the encoder draws a new salt for every encrypted SNMPv3 message.

The salt (msgPrivacyParameters, which the AES IV and the DES pre-IV XOR are built from) was state the caller held:
InitPacket drew it from counters inside the client's UsmSecurityParameters, and MarshalMsg encrypted with
whatever the packet's PrivacyParameters held. So:

  • a packet built from a decoded message reused the received salt. Netdata's trap receiver builds its inform responses
    that way, so a response was encrypted with the inform's key and IV;
  • a packet without a salt was encrypted with an empty AES salt, or panicked for DES;
  • an SNMPv3 packet without security parameters panicked.

Now appendV3 takes the next value of one process-wide 64-bit counter (internal/usm, seeded from crypto/rand,
atomic) for each message whose flags ask for privacy, and passes it to the USM marshaller and the cipher; the scoped
PDU is encrypted exactly when a salt was drawn. The packet's PrivacyParameters are neither used nor written on
encode; decryption still reads the received salt. A packet without security parameters is an error.

Behavior changes:

  • MarshalMsg, SnmpEncodePacket and requests ignore a caller's PrivacyParameters.
  • InitPacket does nothing and is deprecated (the interface keeps it).
  • DES salts come from the same 64-bit counter instead of the engine boots followed by a 32-bit counter. RFC 3414
    section 8.1.1.1 leaves how the salt varies to the implementation as long as IVs do not repeat, and the receiver uses
    the eight octets as given. The end-to-end suite against snmpd passes for every DES and AES user.

Tests

  • TestReencodeDrawsNewSalt (first commit; fails on master for DES, AES and AES256C) re-encodes a decoded trap as an
    inform response is built: each encoding gets its own salt and decrypts.
  • TestUSMSalts is rewritten for the new rule; TestUSMLog pins the logger hand-off that init did and
    setLogger does now.
  • Golden flips: the encode cases nil-security-parameters (panic -> error) and without-salt (now equal to their
    salted twins), the salt-9 re-encodes of the USM decrypt goldens, and the engine goldens' salt values (a discovery
    no longer draws one, DES counts in 64 bits, the fake agent's answers draw from the same counter). A request whose
    OnRetry hook changes the client's privacy protocol now completes, because the retry encodes the request's own packet.
    The net-snmp recordings are unchanged.
  • Tests that pin bytes set the counter. The encode goldens' encrypted cases carry a packet salt of their own, which
    must not reach the wire or the cipher (their bytes are unchanged). The decode goldens re-encode with the received
    salt, so "identical" still shows the codec round trip. The fake agent no longer pins the parse error of a wrong-key
    decryption: those random bytes reached a 32-bit-only length error under GOARCH=386.

Validation

  • golangci-lint, race tests, FIPS 140-only, six cross-builds, govulncheck, netsnmp playback: pass locally;
    GOARCH=386 and the end-to-end suite against snmpd 5.9.3 pass in Docker.
  • No variable moves to the heap; Encode, USMEncode and GetV3 benchmarks show no time or allocation change (8
    interleaved rounds against master).
  • 20 mutants of the changed code (among them a cipher that uses the packet's salt, unseeded and constant-seeded
    counters, a non-atomic counter): all killed but one. It draws the salt before the protocol and level checks, and is
    accepted: which message consumes a counter value has no meaning beyond uniqueness.
  • Two independent reviews (security of the salt rule; test integrity and docs): one stale comment and four test gaps,
    fixed in the last commit.

TestReencodeDrawsNewSalt encodes a packet built from a decoded encrypted
message, as Netdata's trap receiver builds an inform response, twice. Both
encodings carry the received salt, so the response reuses the inform's AES
IV (or DES pre-IV XOR salt) with the same key. The test fails until the
encoder draws a salt for every message.

TestUSMLog pins that prepare hands the client's Logger to the security
parameters, which Log writes to.
The salt (msgPrivacyParameters) was state the caller held: InitPacket drew
it from counters inside the client's UsmSecurityParameters, and MarshalMsg
encrypted with whatever the packet's PrivacyParameters held. A packet built
from a decoded message, as Netdata's inform responses are, reused the
received salt, so its AES IV equalled the inform's; a packet without a salt
was encrypted with an empty AES salt, or panicked for DES.

The encoder now draws the salt: appendV3 takes the next value of one
process-wide 64-bit counter (usm.Salts, seeded from crypto/rand) for every
message its flags encrypt and passes it to the USM marshaller and the
cipher; the packet's PrivacyParameters are neither used nor written. DES
salts come from the same counter instead of the engine boots followed by a
32-bit counter (RFC 3414 section 8.1.1.1 leaves the integer to the
implementation; the receiver uses the eight octets as given). The salt
counters, GoSNMP.initPacket and the USM salt helpers are gone; InitPacket
does nothing and is deprecated. An SNMPv3 packet without security
parameters is an error instead of a panic.

Tests set the counter where they pin bytes: the encode and engine goldens,
the USM fixtures, the net-snmp recordings (unchanged) and the re-encode
line of the decode goldens, which uses the received salt. The engine
goldens' salts change (a discovery no longer draws one, DES counts in 64
bits, the fake agent's answers draw from the same counter); a request whose
OnRetry hook changes the client's privacy protocol now goes out with the
packet's own; and the fake agent no longer pins the parse error of a scoped
PDU decrypted with a wrong key, which depends on the salt and on the width
of the platform's int.
… the seeding

appendV3ScopedPDU encrypts when appendV3 drew a salt instead of reading the
flags again, so one decision gives both the salt on the wire and the
encryption.

The tests missed two mutants. A cipher that used the packet's salt while
the wire carried the drawn one went unnoticed for DES: a wrong DES IV
garbles only the first eight octets, the context engine ID. The encode
goldens' encrypted cases now carry a packet salt of their own (the bytes do
not change), and TestReencodeDrawsNewSalt compares the decoded context
fields. TestSaltsSeeded checked only that a seeded counter does not start
at 1: it now requires two seeded counters to start apart and the
process-wide counter to be seeded. TestSaltCounterConcurrent starts its
goroutines together.

The exchange.encode comment still said it draws the privacy parameters by
the client's flags; it draws only the message ID now.
@ilyam8
ilyam8 added this pull request to stack #61 October 13, 2026 22:36
@ilyam8 ilyam8 changed the title salt per message fix: draw a new salt for every encrypted SNMPv3 message Oct 13, 2026
@ilyam8
ilyam8 merged commit a426df1 into master Oct 14, 2026
21 checks passed
@ilyam8
ilyam8 deleted the salt-per-message branch October 14, 2026 08:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants