Diffie-Hellman USM key change

RFC 2786 defines SNMP-USM-DH-OBJECTS-MIB, which changes a USM key by agreeing a new one over the wire. Neither the old key nor the new one is ever transmitted, and – unlike the RFC 3414 usmUserAuthKeyChange this engine has always supported – the old key is not needed to perform the change. Only write access to the row is. That is what makes provisioning an agent remotely practical rather than a chicken-and-egg problem.

How the exchange works

The agent publishes Diffie-Hellman parameters in usmDHParameters and, for each row of usmDHUserKeyTable, a public value. A manager reads both, offers a public value of its own, and both sides arrive at a shared secret neither sent. The new key is the right-most bits of that secret – as many as the row’s authentication or privacy protocol needs.

The key that results is already localized to the agent: it is derived from a secret only that agent could have computed, so it is used directly rather than being run through RFC 3414 key localization.

Changing a key

dh_key_change() runs the whole exchange:

"""
Change a USM authentication key by Diffie-Hellman agreement
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Rotate an agent's authentication key without either key crossing the wire and
without needing the old one, per :RFC:`2786`.

The agent must implement SNMP-USM-DH-OBJECTS-MIB, and the credentials used must
have write access to the row. Net-SNMP implements it when built with the
``snmp-usm-dh-objects-mib`` module, which distribution packages usually leave out.
"""

import asyncio

from pysnmp.hlapi.asyncio import (
    ContextData,
    SnmpEngine,
    UdpTransportTarget,
    UsmUserData,
    dh_key_change,
    get_cmd,
    usmHMACSHAAuthProtocol,
    usmKeyTypeLocalized,
)
from pysnmp.proto.rfc1902 import OctetString
from pysnmp.smi.rfc1902 import ObjectIdentity, ObjectType


async def run():
    """Rotate the key, then use the new one."""
    current = UsmUserData(
        "usr-sha-none", "authkey1", authProtocol=usmHMACSHAAuthProtocol
    )
    target = await UdpTransportTarget.create(("demo.pysnmp.com", 161))

    result = await dh_key_change(
        SnmpEngine(),
        current,
        target,
        ContextData(),
        "auth",
    )

    # The agent re-keyed when the SET committed, so `current` is now stale. The
    # derived key is already localized to this agent, and USM needs to be told
    # which agent that is -- both come back from the exchange.
    rekeyed = UsmUserData(
        "usr-sha-none",
        result.key,
        authProtocol=usmHMACSHAAuthProtocol,
        authKeyType=usmKeyTypeLocalized,
        securityEngineId=OctetString(result.securityEngineId),
    )

    errorIndication, errorStatus, errorIndex, varBinds = await get_cmd(
        SnmpEngine(),
        rekeyed,
        await UdpTransportTarget.create(("demo.pysnmp.com", 161)),
        ContextData(),
        ObjectType(ObjectIdentity("SNMPv2-MIB", "sysDescr", 0)),
    )

    if errorIndication:
        print(errorIndication)
    elif errorStatus:
        print(
            f"{errorStatus.prettyPrint()} at {errorIndex and varBinds[int(errorIndex) - 1][0] or '?'}"
        )
    else:
        for varBind in varBinds:
            print(" = ".join([x.prettyPrint() for x in varBind]))


asyncio.run(run())

Download the asyncio example.

The synchronous facade offers the same call under pysnmp.hlapi.dh_key_change, blocking until the exchange completes.

Using the result

The agent installs the new key when the SET commits, so the credentials that ran the change stop working the moment it returns. Build fresh credentials from the result, which carries both halves of what UsmUserData needs:

  • key – the new localized key, passed with authKeyType=usmKeyTypeLocalized (or privKeyType for a privacy key).

  • securityEngineId – the agent’s authoritative engine ID. A localized key is bound to one engine and USM will not accept it without being told which, so this is not optional. The exchange learns it from the row index, which saves a separate discovery.

Anything that fails raises DHKeyChangeError rather than returning an error indication, because a key change that half happened leaves the caller unable to reach the agent.

When the SET is the step that fails

Everything the result needs is derived before the SET, so a parameter or key length that cannot produce a key is refused while the agent is still untouched.

The SET itself is the step that cannot be taken back, and its failure is ambiguous. SNMP over UDP gives no way to tell a refused SET from one that committed and whose response was lost, and RFC 2786 defines no recovery for that. A transport target with retries makes it likelier to surface as wrongValue than as a timeout, because a successful SET makes the agent publish a fresh public value, so the retransmitted copy no longer matches the half it echoes.

So the exception carries what is needed to find out:

try:
    result = dh_key_change(engine, current, target, ContextData())
except DHKeyChangeError as exc:
    if exc.candidate is not None:
        # The agent may have re-keyed to this. Try it before concluding
        # that the old credentials are still live.
        ...
    raise

Pass a transport target with retries=0 if a timeout is the clearer signal for your recovery path; the exchange does not override the retry policy it is given.

Finding the row

usmDHUserKeyTable is indexed by engine ID and user name. By default the table is walked to find the row for the user being changed, so callers do not need to know the engine ID in advance. An agent that does not permit walking the table can be addressed directly by passing securityEngineId.

What is supported

The manager side of the key change is implemented: all four DHKeyChange columns, for authentication and privacy keys, for a named user or for the agent’s own key. pysnmp.proto.secmod.rfc2786 also provides the kickstart key derivation (PBKDF2 with the salts the MIB fixes) for reading a usmDHKickstartTable.

The agent side is not implemented – pysnmp does not serve these objects – and no agent-side implementation of usmDHKickstartTable is known to exist to interoperate with.

None of this needs a cryptographic library: the agreement is modular exponentiation, and the one derivation the RFC names is PBKDF2 from hashlib.

Interoperability

The exchange is tested end to end against Net-SNMP in CI, under the v3-dh profile of the Net-SNMP integration workflow. That profile runs an agent built with the snmp-usm-dh-objects-mib module, which is not in Net-SNMP’s default module set and which distribution packages – Debian’s snmpd among them – leave out. An agent without it answers noSuchObject for the whole 1.3.6.1.3.101 subtree.

The test asserts more than that the exchange completed: it authenticates with the derived key afterwards, which is the only way to observe that the key the agent installed is the key this engine computed.

API reference

async pysnmp.hlapi.asyncio.dh_key_change(snmpEngine: Any, authData: Any, transportTarget: Any, contextData: Any, keyType: str = 'auth', *, own: bool = False, userName: bytes | str | None = None, securityEngineId: bytes | None = None, keyLength: int | None = None, **options: Any) → DHKeyChangeResult

Change a USM key by Diffie-Hellman agreement (RFC 2786).

Neither the old key nor the new one crosses the wire, and the old one is not needed to run this – only write access to the row. That is what usmUserAuthKeyChange cannot offer, and what makes provisioning an agent remotely practical.

The agent installs the new key when the SET commits, so the credentials used for this call stop working the moment it returns. Build fresh UsmUserData from the returned key, passing authKeyType=usmKeyTypeLocalized.

Parameters:
  • snmpEngine (SnmpEngine) – Class instance representing SNMP engine.

  • authData (UsmUserData) – The credentials to run the exchange under. Their user name names the row unless userName overrides it, and their protocols say how long the new key is unless keyLength overrides it.

  • transportTarget (TransportTarget) – Class instance representing transport type along with SNMP peer address.

  • contextData (ContextData) – Class instance representing SNMP ContextEngineId and ContextName values.

  • keyType (str, optional) – "auth" for the authentication key, "priv" for the privacy key.

  • own (bool, optional) – Use the usmDHUserOwn* column, which is how an agent changes its own key rather than the named user’s.

  • userName (bytes or str, optional) – The USM user whose row to change, when it is not the one authenticating.

  • securityEngineId (bytes, optional) – The agent’s authoritative engine ID. Given, the row is addressed directly; omitted, the table is walked to find it.

  • keyLength (int, optional) – Octets of key to derive, when the protocol in authData is not the one the row uses.

  • **options – Passed through to the underlying commands.

Returns:

DHKeyChangeResult – The new localized key, the instance that was set and the parameters used.

Raises:

DHKeyChangeError – If any step fails. Everything is derived before the SET, so the agent’s key is unchanged unless the SET itself was the step that failed – and that case carries a candidate result, because a failed SET may still have been applied. A transport target with retries turns a lost response into a wrongValue on the retransmission rather than a timeout; pass a target with retries=0 if a timeout is the clearer signal for your recovery path.

Examples

>>> import asyncio
>>> from pysnmp.hlapi.asyncio import *
>>> from pysnmp.proto.rfc1902 import OctetString
>>>
>>> async def run():
...     result = await dh_key_change(
...         SnmpEngine(),
...         UsmUserData('user', 'authkey1'),
...         await UdpTransportTarget.create(('localhost', 161)),
...         ContextData(),
...     )
...     return UsmUserData(
...         'user',
...         result.key,
...         authKeyType=usmKeyTypeLocalized,
...         securityEngineId=OctetString(result.securityEngineId),
...     )
>>>
>>> rekeyed = asyncio.run(run())
class pysnmp.hlapi.asyncio.DHKeyChangeResult(key: bytes, securityEngineId: bytes, instance: tuple[int, ...], parameters: DHParameters)

What a completed key change produced.

key

The new key, already localized to this agent.

Type:

bytes

securityEngineId

The agent’s authoritative engine ID. Returned because a localized key is only usable with the engine ID it is bound to, and this exchange has already learned it – pass both to UsmUserData.

Type:

bytes

instance

The DHKeyChange instance that was SET.

Type:

tuple of int

parameters

The agreement parameters the agent published.

Type:

pysnmp.proto.secmod.rfc2786.DHParameters

Create new instance of DHKeyChangeResult(key, securityEngineId, instance, parameters)

class pysnmp.proto.secmod.rfc2786.DHParameters(prime: int, base: int, privateValueLength: int | None = None)

The prime, base and optional private-value length of an agreement.

Frozen and made only of integers, so importing this module costs nothing and the well-known groups below can be module constants.

prime

The prime modulus p.

Type:

int

base

The generator g.

Type:

int

privateValueLength

The optional length l in bits that bounds the private exponent. When absent the exponent is drawn from the whole interval below p - 1.

Type:

int or None

property primeLength: int

The prime’s length in octets, which is the width of a shared secret.

class pysnmp.hlapi.asyncio.DHKeyChangeError(message: str, candidate: DHKeyChangeResult | None = None)

A Diffie-Hellman key change did not complete.

Raised rather than returned, because a key change that half happened leaves the caller unable to talk to the agent, and a return value is too easy to ignore.

candidate

Set when the SET was the step that failed, and then the failure is ambiguous: the agent may have applied the change and lost the response. RFC 2786 defines no recovery for that, so the way to find out is to try this key. None for every other step, where nothing was changed.

Type:

DHKeyChangeResult or None

Record the message and, for a failed SET, the key that may be live.