Skip to content

feat: act on SNMPv3 Reports sent without authentication - #48

Merged
ilyam8 merged 22 commits into
masterfrom
engine-report-resync
Oct 11, 2026
Merged

ilyam8 merged 22 commits into
masterfrom
engine-report-resync

Conversation

@ilyam8

@ilyam8 ilyam8 commented Oct 11, 2026 •

Copy link
Copy Markdown
Member

Summary

Ports the SNMPv3 REPORT resynchronization the Netdata Agent ships today from a personal fork, so the Agent can move to
this module without losing it. This is the one behavior change of the engine phase.

Some agents (UniFi access points) answer a request whose engine time is stale with a notInTimeWindow Report sent at
noAuthNoPriv, with the current engine boots and time in its header (RFC 3414 section 3.2 step 7 a asks for
authNoPriv). The client checked every reply of an authenticated request against its digest, discarded the Report, and
timed out on every request with the same stale time.

Acting on such Reports needs every other reply judged against the request it answers, so this PR also checks each
SNMPv3 reply against its request (RFC 3412 section 7.2, RFC 3414 section 3.2).

Behavior

Replies are judged against their request:

  • A reply to an authenticated request must ask for authentication, name the request's user and engine ID, and carry
    a digest that checks with the request's key. Keys are no longer derived for the engine ID a reply names, so another
    agent with the same credentials cannot answer for this one, and the engine no longer skips the digest of a reply
    with an empty user name and engine ID (the discovery exception). UnmarshalTrap is unchanged.
  • Every SNMPv3 reply must carry the msgID of one of the request's attempts; SNMPv3 replies no longer answer SNMPv1 or
    SNMPv2c requests.
  • The client stores engine parameters only from a reply checked this way, and at noAuthNoPriv from other replies too,
    never from a Report it could not check; engine discovery (RFC 3414 section 4) still learns the engine ID, boots and
    time from its unauthenticated answer. No reply to an authenticated request replaces the client's engine ID: when the
    agent's engine ID changes, such requests now end with ErrUnknownEngineID, as in net-snmp; an application that
    follows replaced devices clears the client's engine ID, and the context engine ID gosnmp learned with it, on that
    error so the next request rediscovers.
  • An authenticated request without a key localized to the agent's engine ID fails with an error instead of going out
    signed with an empty key (after a discovery answer without an engine ID, or a configured key that discovery
    replaced). Under FIPS 140-only, MD5 and SHA-1 users now get this error instead of the hash's FIPS error, because key
    derivation drops that error (a known bug for the bug-fix phase).

Reports without authentication (the port):

  • A Report at noAuthNoPriv answering an authenticated request is read when its scoped PDU is plaintext, it has the
    request's security model and exactly one varbind, and it carries the msgID of one of the request's attempts. Nothing
    unauthenticated is decrypted; a reply flagged privacy without authentication is not read.
  • Its counter's error ends the request (ErrUnknownUsername, ErrWrongDigest, ErrDecryption, ...) instead of a
    timeout. wrongDigest is not resent.
  • notInTimeWindow: a read request (Get, GetNext, GetBulk, so walks too) is sent once more with the Report's engine
    boots and time in the request only, when the Report names the request's engine ID; the client keeps engine
    parameters from authenticated messages only (RFC 3414 section 3.2 step 7) and stores the authenticated answer's.
    Another authenticated request (Set) ends with ErrNotInTimeWindow: a resend signed with a time an unauthenticated
    message names could be replayed when the agent's clock gets there.
  • unknownEngineID: an authenticated request ends with ErrUnknownEngineID and the engine ID is kept; a resend
    signed for the engine the Report names could reach another agent with the same credentials. Requests at
    noAuthNoPriv, which carry no signature, are sent once more with the Report's engine ID, boots and time.

The resynchronization in send:

  • The retransmission returns its own error; a resync Report answering it ends the request with its error (it was
    returned with a nil error); the engine parameters of the retransmission's answer and of authenticated error Reports
    are stored; a resync Report the client cannot store ends the request with the store error (an error Report keeps its
    own error).

Commits

  • test: commits pin each case on the code before the change that flips it: a UniFi model after an engine time jump
    or a reboot at authNoPriv, AES and DES; the captured Report with request ID 0; forged times; other and earlier
    msgIDs; encrypted, empty, two-varbind, undecodable and privacy-only Reports; another security model; engine-ID
    changes; reads and Sets; Reports another agent with the same credentials signed; replies naming another engine ID;
    replies signed with the empty key; SNMPv3 replies to SNMPv2c requests.
  • fix: the discovery exception only while discovering (later removed from the engine by the reply check).
  • feat: the port; fix: review findings; fix: the read-only and no-other-engine rule; fix: the msgID binding
    of a privacy-only Report while discovering.
  • fix: judge an SNMPv3 reply against the request it answers; fix: never sign or check a reply with an empty key;
    fix: refuse a request without a key whatever the client's version; docs: comment corrections.
  • The golden diff of each commit lists every changed outcome; each removed known bug: note is a behavior fixed here.

Validation

  • Engine goldens: every changed outcome follows from a rule above; walk unchanged; v3-fips changes three error
    texts (above).
  • 89 mutation probes of the changed code (the author's and a reviewer's): 80 killed; eight survivors are equivalent
    (a path an earlier check already closes, identical values, or a digest that would need an HMAC fixed point), one
    changes only allocations on a retry.
  • Allocations per request unchanged, time within noise.
  • go test -race, FIPS 140-only, GOARCH=386, the end-to-end suite against snmpd 5.9.3, golangci-lint,
    cross-builds, govulncheck: pass locally.
  • Independent review: an adversarial design review, two reviews of the port and a focused re-review; an external final
    review (two state regressions, fixed by the reply check); a design review of the reply check and two reviews of it
    (an adversarial attack matrix and a coverage review); their findings are fixed here or listed for the bug-fix phase.

Not tested on a UniFi device: the fake agent models the captured Report.

ilyam8 added 11 commits October 11, 2026 13:47
Scenarios for the Reports the REPORT resync will act on: an agent that
answers stale requests with notInTimeWindow Reports at noAuthNoPriv
after an engine time jump or a reboot (authNoPriv, AES and DES), a
Report naming another engine, forged engine times, Reports for another
or an earlier attempt's msgID, an unknownEngineID Report for another
message, an encrypted Report flagged noAuthNoPriv, Reports with two or
no varbinds, and an engine ID change at authPriv. Today the client
discards every one of them.
The capture's Report (request ID 0, matched by its msgID alone), a
forged authNoPriv Report without a user name and engine ID, an
unauthenticated Report with a varbind that does not decode or with
another security model, an agent silent after an unauthenticated
unknownEngineID Report, a hook changing the security model after an
unauthenticated notInTimeWindow Report, an authNoPriv Report to a
noAuthNoPriv request of a user with keys, and SNMPv3 GetResponse and
unknownEngineID Report answers to an SNMPv2c request.

The transcript names a plaintext scoped PDU it cannot decode as such
instead of calling it encrypted.
The engine accepted any reply with an empty user name and engine ID
without its digest, after discovery too: a GetResponse so made was
returned as an answer and emptied the client's engine ID, and a forged
authNoPriv Report so made counted as authenticated. Once the client
knows the agent's engine ID, a reply is now checked with the client's
flags and keys without the exception. UnmarshalTrap keeps it.
Some agents (UniFi access points) answer a request whose engine time is
stale with a notInTimeWindow Report at noAuthNoPriv. The client checked
every reply of an authenticated request against its digest, discarded
the Report and timed out with the same stale time on every request.

A Report without the authentication its request asked for is now read
when its scoped PDU is plaintext, it has the request's security model
and exactly one varbind, and it carries the msgID of one of the
request's attempts. Its counter's error ends the request, wrongDigest
included. A notInTimeWindow or unknownEngineID Report sends the request
once more with the Report's boots and time, and the Report's engine ID
for unknownEngineID, in the request only: the client keeps engine
parameters from authenticated messages only (RFC 3414 section 3.2 step
7) and stores the authenticated answer's.

The resynchronization in send also changes: the retransmission returns
its own error, a resync Report answering it ends the request with its
error instead of a nil one, the engine parameters of the
retransmission's answer and of authenticated error Reports are stored,
and a Report the client cannot store ends the request with the store
error instead of being acted on.
An unauthenticated Report that ends after its security parameters, one
flagged privacy without authentication, an authenticated error Report
and a retransmission's answer with another security model, and a hook
that lowers the client's msgFlags before a forged Report is decoded.
- A reply flagged privacy without authentication is not read as an
  unauthenticated Report (RFC 3412 section 7.2 discards it).
- After discovery the engine checks a reply's digest with its request's
  msgFlags and user, the ones the acceptance and store rules use, so a
  hook that changes the client's msgFlags cannot let a forged Report be
  stored as authenticated.
- An error Report keeps its error when its parameters cannot be stored,
  so errors.Is still classifies it.
- The IDs of earlier attempts live in an eight-entry array on the stack:
  a slice made for Retries+1 entries escaped beyond Retries=3.
- Comments say which keys check a reply and that a noAuthNoPriv session
  stores no Report's parameters; notes on the SNMPv3 answers to an
  SNMPv2c request and on the discovery exception's stored engine time.
testing.AllocsPerRun counts differently under the race detector; the
allocations per request stay checked by the benchmarks.
GetNext and GetBulk after an engine time jump with unauthenticated
notInTimeWindow Reports, a Set in the same case and after a reboot
(authenticated Report), and a Set of a noAuthNoPriv session answered by
a notInTimeWindow Report.
…misuse

An authenticated request resent with what an unauthenticated Report
names is signed with it. A notInTimeWindow Report naming a future time
would get a signed copy an on-path sender replays when the agent's
clock gets there, and an unknownEngineID Report naming another agent's
engine ID would get the request signed for that agent where it shares
the user's credentials.

So an authenticated request takes an unauthenticated Report's engine
parameters only as a read (Get, GetNext, GetBulk), whose replayed copy
is a read, and an engine ID only while the client has none
(discovery). Otherwise the request ends with the Report's error: a Set
with ErrNotInTimeWindow, any request with ErrUnknownEngineID once the
client knows the agent's engine ID, as net-snmp does. A configured
engine ID is no longer overridden. Requests at noAuthNoPriv, which
carry no signature, resynchronize as before.
After a discovery answer without an engine ID, an unknownEngineID
Report from another engine for another message, at noAuthNoPriv and
flagged privacy without authentication.
Requiring noAuthNoPriv in withoutAuthentication kept a Report flagged
privacy without authentication out of the acceptance path, but also out
of the msgID check: while the client has no engine ID such a Report is
read with its own flags, and one for another message could name the
engine the request is then sent to. The msgID check covers again every
Report without the authentication bit; the acceptance path alone
requires noAuthNoPriv.
@ilyam8 ilyam8 changed the title engine report resync feat: act on SNMPv3 Reports sent without authentication Oct 11, 2026
ilyam8 added 11 commits October 11, 2026 15:41
For another message and with the request's msgID: the client takes the
other agent's engine and its later requests to its own agent fail.
An empty GetResponse for another message, an SNMPv3 Report with msgID 0
answering an SNMPv2c request, and a hook that sets the client's version
to 2c after an unauthenticated notInTimeWindow Report.
The engine judged a reply by its own claims: USM decoding derives keys
for the engine ID a reply names, so any agent sharing the user's
credentials produced authentic replies; a Report carrying the
authentication flag skipped the msgID check; "authenticated" was read
from header flags; and the discovery exception applied while the
client had no engine ID. With the resync storing more replies, a Report
another agent signed for another message moved the client to that
agent's engine for good, and a forged empty-identity Report stored its
time.

Now (RFC 3412 section 7.2, RFC 3414 section 3.2):
- a reply to an authenticated request must ask for authentication,
  name the request's user and engine ID, and carry a digest that checks
  with the request's key; decode reports that as an explicit verified
  value, and the engine no longer applies the discovery exception
  (UnmarshalTrap keeps it);
- an SNMPv3 request is answered only by an SNMPv3 reply with the msgID
  of one of its attempts, and an SNMPv3 reply answers nothing else;
- engine parameters are stored from verified replies, an unverified
  one only in a noAuthNoPriv session and never a Report, so no reply
  replaces a known engine ID.
After a discovery answer without an engine ID the client has no keys,
and a user with a localized key but an empty passphrase loses its key at
discovery; in both a forged reply signed with the empty key is returned
and its engine time stored.
After a discovery answer without an engine ID no key is derived, and a
user with a localized key but an empty passphrase loses it at
discovery: the client signed requests and verified replies with the
empty key, so anyone could forge a reply. The engine now refuses to
encode an authenticated request, or to verify an answer, without a
localized key. An authenticated request also never takes an engine ID
from an unauthenticated Report, and takes a notInTimeWindow Report's
time only when the Report names the request's engine. decode judges a
reply by the request's version.
The digest alone does not tie a reply to the request's engine: a reply
that names another engine ID but is signed with the key localized to the
agent's is discarded, and the client keeps its engine ID.
A notInTimeWindow Report naming another engine ID lends its time only,
and after a discovery answer without an engine ID the agent's
unknownEngineID Report lends the engine ID and the missing context
engine ID.
After a discovery answer without an engine ID, a Logger that sets the
client's version to SNMPv2c after the negotiation makes encode skip the
key check, so the SNMPv3 request goes out signed with the empty key.
… guards

- An authentic Report for another message, and an SNMPv2c message whose
  body reads as a scoped PDU, answer no SNMPv3 request.
- The scenarios after a discovery answer without an engine ID use a
  configured key, which survives the discovery, so their forged second
  answers reach the client again; the scenario they duplicated is
  dropped, and the key-only scenario keeps no unreachable script.
- A Logger that changes the client's version after a normal discovery.
…sion

The key check ran only when the client's version was SNMPv3, but the
packet is signed by its own version, so a Logger that changed the
client's version after the negotiation let an authenticated request go
out signed with the empty key. The check now follows the packet, as the
signature does.
decode gates on the request's version; answers does not compare the user
and engine ID of a reply to a request without authentication; mayLend
lends a time from a Report naming the request's engine ID; authenticate
also receives replies of other versions; errNotAuthentic covers a missing
authentication flag.
@ilyam8
ilyam8 force-pushed the engine-report-resync branch from 5461b7c to 6eb526e Compare October 11, 2026 13:54
@ilyam8
ilyam8 merged commit 0d37d3a into master Oct 11, 2026
21 checks passed
@ilyam8
ilyam8 deleted the engine-report-resync branch October 11, 2026 14:47
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