Skip to content

fix: make SNMPv3 keys valid by construction - #64

Merged
ilyam8 merged 5 commits into
masterfrom
key-validity
Oct 14, 2026
Merged

ilyam8 merged 5 commits into
masterfrom
key-validity

Conversation

@ilyam8

@ilyam8 ilyam8 commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

Summary

Fifth step of the bug-fix phase: an SNMPv3 key is valid for its protocol by construction.

The USM took keys and salts of any length:

  • HMAC-MD5-96 and HMAC-SHA-96 cut a key longer than the 64-octet block (RFC 2104 hashes it); crypto/hmac panicked
    for an HMAC-SHA-2 key under 14 octets in FIPS 140-only mode.
  • DES indexed past a key shorter than 16 octets or a salt shorter than 8, and panicked, on decode and encode.
  • AES took any key aes.NewCipher takes, so an AES-128 key of 32 octets encrypted with AES-256, and any salt (9 and
    16 octets were accepted inbound). AES-CFB panicked in FIPS 140-only mode.
  • Localizing an empty passphrase dropped the error and returned an empty key. A new engine ID gave a user configured
    with keys and no passphrases the empty keys, so a trap signed with the empty key, which anyone can compute, passed,
    and so did an unauthenticated trap from any engine ID for keys-only DES and no-privacy users.
  • Per-site checks stood in for the missing rule: guards in the engine and in the answer check refused an empty key;
    SnmpEncodePacket signed with one; the signer recovered a panic onto stdout and returned no message and no error.

Now:

  • One rule in the internal/usm specs every path reaches: an authentication key is exactly the size of its hash, a
    privacy key exactly its cipher's key (AES's, or DES's 16 octets of key and pre-IV, RFC 3414 section 8.1.1.1), a salt
    exactly 8 octets (RFC 3414 section 8.3.2, RFC 3826 section 3.3.2). Digest, Verify, Encrypt and Decrypt
    refuse anything else, and AES-CFB in FIPS 140-only mode, with an error.
  • Localizing an empty passphrase is an error. InitSecurityKeys derives the missing keys from non-empty passphrases
    and checks every key with the rule, so a table's Add refuses a key that does not fit.
  • A key in the parameters is localized to AuthoritativeEngineID (RFC 3414 section 2.6): a new engine ID re-derives
    the keys from the passphrases (traps from new engines keep working), and parameters with a key given without its
    passphrase cannot take another engine ID. Such a user decodes no message naming another engine ID, whatever its
    security level, security model or user name, and such a client takes no engine ID from discovery.
  • The signer's recover, the engine's empty-key guard and the answer check's empty-key test go: the rule catches what
    they caught, with an error.

Behavior changes:

Case Before After
Authentication key not of the hash size used (cut or zero-padded) error
AES key of another size than the protocol's used (another AES size) error
DES key shorter than 16 octets, or salt shorter than 8 panic error
Salt longer than 8 octets accepted error
AES-CFB in FIPS 140-only mode panic error
Empty passphrase empty key error, no key
Trap signed with the empty key, user with keys only accepted rejected
Unauthenticated trap from another engine ID, user with keys only accepted (DES, no privacy, long-hash Blumenthal); rejected by a failed key derivation (other privacy protocols) rejected
Client with keys only and no engine ID DES, no privacy: discovery adopts the agent's, signing fails for want of a key; other privacy protocols: discovery fails (a failed key derivation) discovery fails, the client's security parameters unchanged
Client with keys only, reply naming another engine ID (also a forged Report) DES, no privacy: ErrUnknownEngineID; other privacy protocols: undecodable (a failed key derivation) undecodable: sent again, then the binding error
Signing panic message dropped, stack on stdout cannot happen; key errors returned
DES key derived for an HMAC-SHA-family user the 20+-octet localized key its first 16 octets (same ciphertext)

Passphrases stay accepted at any non-empty length (RFC 3414 section 11.2 asks for 8 octets; Netdata's jobs may use
shorter ones). Netdata uses passphrases only; its trap receiver's error labels keep working ("authentication",
"decrypting").

Tests

  • First commits: pins of the key and salt lengths the USM took beyond its protocols'.
  • Golden flips: the codec's 80-octet keys and DES decode panic; the USM key golden (DES keys of 16 octets, empty
    passphrases); the USM decrypt golden (DES panics and wrong salts become errors); the engine goldens (the rule's
    errors replace the guard's, AES-CFB errors replace recovered panics).
  • Rule tests: TestDigest (keys of 0 to 100 octets), TestPrivKeyAndSaltLengths, TestUSMKeyLengths,
    TestUSMEmptyPassphraseKeys, TestSnmpEncodePacketEmptyKey, TestUSMFIPS140OnlyShortSHA2Key, the hand-forged
    empty-key trap of TestUSMTrapUnauthenticated; the RFC 3414 key vectors still match.
  • Engine binding: TestUSMKeysBoundToEngine (every protocol pair; the trap table, a single trap user with its own or
    the message's flags, and SnmpDecodePacket; unauthenticated, security model 2 and empty-identity messages from
    another engine rejected, signed ones from the keys' engine accepted), TestUSMAdoptEngineID (the rule per parameter
    shape), engine scenarios for a keys-only client with another configured engine ID and a forged Report from another
    engine.

Validation

  • Local: golangci-lint, race tests, FIPS 140-only, six cross-builds, govulncheck and netsnmp playback pass.
    GOARCH=386 and the end-to-end suite run in CI.
  • Benchmarks: allocations and bytes of USM encode and decode and of an SNMPv3 Get are unchanged; time within about 3%.
  • 36 mutants of the changed code: all killed but one equivalent.
  • Two independent reviews (security of the key invariant; tests and docs): no forgery, key or salt slipped past the
    rule on any path, FIPS 140-only included. Their findings are in the last commit: every cipher error wrapped at one
    point (DES refused in FIPS 140-only mode now reads like AES-CFB's refusal), one privacy-key check, tests of the
    error words Netdata's trap receiver labels, and two doc corrections.
  • An external final review found that dropping a key given without a passphrase let messages that use no key through
    (unauthenticated traps from other engines to keys-only users). The last commit binds such parameters to their engine
    ID instead; the review's own probes pass.

@ilyam8 ilyam8 changed the title key validity fix: make SNMPv3 keys valid by construction Oct 14, 2026
@ilyam8
ilyam8 marked this pull request as ready for review October 14, 2026 10:38
Comment thread internal/usm/priv.go Dismissed
Encrypt and Decrypt take an AES-128 key of 32 octets (and encrypt with
AES-256), AES salts of 9 and 16 octets and a DES key of 20 octets.
InitSecurityKeys leaves an HMAC-SHA user the whole 20-octet localized key as
its DES key, and it and a table's Add take keys of another length than the
protocol's: an authentication key that is not the hash size, an AES key of
32 octets for AES-128, a DES key of 8 octets.
The USM took keys and salts of any length: HMAC-MD5/SHA-96 cut a key longer
than the block, crypto/hmac panicked for a short HMAC-SHA-2 key in FIPS
140-only mode, DES indexed past a key shorter than 16 octets or a salt
shorter than 8 and panicked, AES took any key aes.NewCipher takes (an
AES-128 key of 32 octets encrypted with AES-256) and any salt, and AES-CFB
panicked in FIPS 140-only mode. Localizing an empty passphrase dropped the
error and returned an empty key, so a new engine ID gave a user configured
with keys and no passphrases the empty keys, and a trap signed with the
empty key, which anyone can compute, passed. Guards in the engine and the
answer check refused an empty key, SnmpEncodePacket signed with one, and
the signer recovered a panic onto stdout, returning no message and no
error.

One rule, in the internal/usm specs every path reaches: an authentication
key is exactly the size of its hash, a privacy key exactly its cipher's
key (AES's, or DES's 16 octets of key and pre-IV), and a salt 8 octets.
Digest, Verify, Encrypt and Decrypt refuse anything else, and AES-CFB in
FIPS 140-only mode, with an error; decryption errors say "decrypting".
Localizing an empty passphrase is an error. InitSecurityKeys derives the
missing keys from non-empty passphrases and checks each key with the rule.
A key in the parameters is localized to AuthoritativeEngineID: a new
engine ID re-derives the keys from the passphrases and drops a key given
without one, which serves only its engine. The signer's recover, the
engine's guard, the answer check's empty-key test and errAuthKeyRequired
go. A DES key derived for an HMAC-SHA-family user is now the first 16
octets of the localized key; ciphertexts are unchanged.
Encrypt and Decrypt now wrap every error, the cipher constructors' included,
so DES refused in FIPS 140-only mode says "usm: encrypting:" or "usm:
decrypting:" like the AES-CFB refusal. CheckKey alone decides whether a
privacy protocol has a cipher, and PrivacyKey cuts the key and checks it
with the rule, instead of separate no-cipher tests. The discovery doc gives
the real reason a request goes out without a key, and UnmarshalTrap's doc
says parameters with a protocol but neither key nor passphrase fail every
message.

Tests: the trap cases check the error words Netdata's labels read; the
derived DES key is compared with the localized key's prefix; a privacy key
still short after its extension, and a protocol without a cipher, fail;
InitSecurityKeys returns a FIPS 140-only derivation error; the validate
cases use a key of the right size; the empty-key trap is signed with a key
of zeros, which HMAC treats as the empty key.
Adopting another engine ID dropped a key given without its passphrase, so
the key's use failed, but a message that uses no key still passed: a
keys-only trap user accepted noAuthNoPriv, security model 2 and empty-identity
traps from any engine ID, which a failed privacy key derivation had rejected
before for most protocol pairs.

Keys localize to one engine ID (RFC 3414 section 2.6). Adopting another one
derives new keys from the passphrases, so parameters with a protocol whose
passphrase is empty now refuse it with an error and stay unchanged: they
decode no message naming another engine ID, whatever its security level,
model or user, and take no engine ID from discovery. A reply naming another
engine ID than a keys-only client's is undecodable, so the request is sent
again and ends with that error instead of ErrUnknownEngineID.
@ilyam8
ilyam8 merged commit e9a6913 into master Oct 14, 2026
21 checks passed
@ilyam8
ilyam8 deleted the key-validity branch October 14, 2026 19:54
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.

3 participants