From ce5a7cf76217ce1f980c0781d0e8bcdc18a0cdba Mon Sep 17 00:00:00 2001 From: jettwang Date: Mon, 8 Jun 2026 13:29:04 +0800 Subject: [PATCH] Add Message-Authenticator (RFC 3579) sign and validate helpers The library exposed accessors for the Message-Authenticator attribute but provided no way to compute or verify its HMAC-MD5 value. RFC 3579 and RFC 5997 require this attribute to be validated (for example, on Status-Server requests, and as mitigation for Blast-RADIUS style attacks), so users had to implement the HMAC themselves. Add opt-in helpers to rfc2869: - AddMessageAuthenticator places a zeroed placeholder attribute on a packet before it is encoded. - SignMessageAuthenticator computes the HMAC-MD5 over the encoded packet (with the attribute value zeroed) and writes it back. - ValidateMessageAuthenticator verifies it, returning typed errors for the missing and mismatched cases. The computation operates on wire bytes, matching IsAuthenticRequest / IsAuthenticResponse, and is correct for Access-Request and Status-Server packets. The output was cross-checked against an independent HMAC-MD5 implementation. Closes #43 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rfc2869/message_authenticator.go | 124 ++++++++++++++++++++++++++ rfc2869/message_authenticator_test.go | 106 ++++++++++++++++++++++ 2 files changed, 230 insertions(+) create mode 100644 rfc2869/message_authenticator.go create mode 100644 rfc2869/message_authenticator_test.go diff --git a/rfc2869/message_authenticator.go b/rfc2869/message_authenticator.go new file mode 100644 index 00000000..3a9d04e4 --- /dev/null +++ b/rfc2869/message_authenticator.go @@ -0,0 +1,124 @@ +package rfc2869 + +import ( + "crypto/hmac" + "crypto/md5" + "encoding/binary" + "errors" + + "layeh.com/radius" +) + +const messageAuthenticatorValueLength = 16 + +// ErrNoMessageAuthenticator is returned when a packet does not contain a +// Message-Authenticator attribute. +var ErrNoMessageAuthenticator = errors.New("rfc2869: no Message-Authenticator attribute") + +// ErrInvalidMessageAuthenticator is returned by ValidateMessageAuthenticator +// when a packet's Message-Authenticator attribute does not match the expected +// HMAC-MD5 value. +var ErrInvalidMessageAuthenticator = errors.New("rfc2869: invalid Message-Authenticator") + +// AddMessageAuthenticator adds a zeroed, correctly sized Message-Authenticator +// attribute to p, replacing any existing one. +// +// The placeholder attribute must be present before the packet is encoded so +// that SignMessageAuthenticator can fill in the computed value over the final +// wire bytes. +func AddMessageAuthenticator(p *radius.Packet) { + var zero [messageAuthenticatorValueLength]byte + _ = MessageAuthenticator_Set(p, zero[:]) +} + +// SignMessageAuthenticator computes the Message-Authenticator (RFC 3579, +// Section 3.2) for the encoded RADIUS packet contained in wire and writes it +// into the packet's Message-Authenticator attribute. +// +// wire must be a fully encoded RADIUS packet that already contains a 16-octet +// Message-Authenticator attribute (use AddMessageAuthenticator before encoding). +// secret is the shared secret used as the HMAC-MD5 key. +// +// The HMAC is computed over the entire packet with the Message-Authenticator +// value treated as zero, using the Authenticator field currently present in +// wire. This is exactly the required computation for Access-Request and +// Status-Server packets. For response packets, set the Authenticator field to +// the corresponding request's Authenticator before signing and compute the +// Response Authenticator afterwards. +func SignMessageAuthenticator(wire, secret []byte) error { + offset, err := messageAuthenticatorValueOffset(wire) + if err != nil { + return err + } + sum := computeMessageAuthenticator(wire, secret, offset) + copy(wire[offset:offset+messageAuthenticatorValueLength], sum) + return nil +} + +// ValidateMessageAuthenticator verifies the Message-Authenticator attribute +// (RFC 3579, Section 3.2) of the encoded RADIUS packet contained in wire, +// using secret as the HMAC-MD5 key. +// +// It returns nil if the attribute is present and valid, +// ErrNoMessageAuthenticator if the packet has no Message-Authenticator +// attribute, ErrInvalidMessageAuthenticator if the attribute does not match, +// or another error if wire is malformed. +// +// The Authenticator field present in wire is used as the HMAC input, which is +// correct for Access-Request and Status-Server packets. To validate a response +// packet, set the Authenticator field to the request's Authenticator first. +func ValidateMessageAuthenticator(wire, secret []byte) error { + offset, err := messageAuthenticatorValueOffset(wire) + if err != nil { + return err + } + sum := computeMessageAuthenticator(wire, secret, offset) + if !hmac.Equal(sum, wire[offset:offset+messageAuthenticatorValueLength]) { + return ErrInvalidMessageAuthenticator + } + return nil +} + +// messageAuthenticatorValueOffset returns the offset within wire of the 16-octet +// Message-Authenticator value. +func messageAuthenticatorValueOffset(wire []byte) (int, error) { + if len(wire) < 20 { + return 0, errors.New("rfc2869: packet not at least 20 bytes long") + } + length := int(binary.BigEndian.Uint16(wire[2:4])) + if length < 20 || length > len(wire) { + return 0, errors.New("rfc2869: invalid packet length") + } + + for i := 20; i < length; { + if i+2 > length { + return 0, errors.New("rfc2869: invalid attribute") + } + attrLength := int(wire[i+1]) + if attrLength < 2 || i+attrLength > length { + return 0, errors.New("rfc2869: invalid attribute length") + } + if radius.Type(wire[i]) == MessageAuthenticator_Type { + if attrLength != 2+messageAuthenticatorValueLength { + return 0, errors.New("rfc2869: invalid Message-Authenticator length") + } + return i + 2, nil + } + i += attrLength + } + + return 0, ErrNoMessageAuthenticator +} + +// computeMessageAuthenticator returns the HMAC-MD5 over wire (up to its declared +// length) with the 16 octets at valueOffset treated as zero. +func computeMessageAuthenticator(wire, secret []byte, valueOffset int) []byte { + length := int(binary.BigEndian.Uint16(wire[2:4])) + + mac := hmac.New(md5.New, secret) + mac.Write(wire[:valueOffset]) + var zero [messageAuthenticatorValueLength]byte + mac.Write(zero[:]) + mac.Write(wire[valueOffset+messageAuthenticatorValueLength : length]) + return mac.Sum(nil) +} diff --git a/rfc2869/message_authenticator_test.go b/rfc2869/message_authenticator_test.go new file mode 100644 index 00000000..18bdd5c3 --- /dev/null +++ b/rfc2869/message_authenticator_test.go @@ -0,0 +1,106 @@ +package rfc2869 + +import ( + "crypto/hmac" + "crypto/md5" + "encoding/binary" + "errors" + "testing" + + "layeh.com/radius" +) + +func encodeWithMessageAuthenticator(t *testing.T, code radius.Code, secret []byte) []byte { + t.Helper() + + p := radius.New(code, secret) + p.Add(radius.Type(1), radius.Attribute("user")) // User-Name + AddMessageAuthenticator(p) + p.Add(radius.Type(4), radius.Attribute{192, 0, 2, 1}) // NAS-IP-Address + + wire, err := p.Encode() + if err != nil { + t.Fatalf("encode: %s", err) + } + return wire +} + +func TestMessageAuthenticator_SignAndValidate(t *testing.T) { + secret := []byte("s3cr3t") + + for _, code := range []radius.Code{radius.CodeAccessRequest, radius.CodeStatusServer} { + wire := encodeWithMessageAuthenticator(t, code, secret) + + if err := SignMessageAuthenticator(wire, secret); err != nil { + t.Fatalf("code %v: sign: %s", code, err) + } + + if err := ValidateMessageAuthenticator(wire, secret); err != nil { + t.Fatalf("code %v: validate after sign: %s", code, err) + } + + // Independently recompute the HMAC over the packet with the + // Message-Authenticator value zeroed and confirm it matches what Sign + // wrote. + offset, err := messageAuthenticatorValueOffset(wire) + if err != nil { + t.Fatalf("code %v: offset: %s", code, err) + } + length := int(binary.BigEndian.Uint16(wire[2:4])) + check := make([]byte, length) + copy(check, wire[:length]) + for i := offset; i < offset+messageAuthenticatorValueLength; i++ { + check[i] = 0 + } + mac := hmac.New(md5.New, secret) + mac.Write(check) + if !hmac.Equal(mac.Sum(nil), wire[offset:offset+messageAuthenticatorValueLength]) { + t.Fatalf("code %v: signed value does not match independent HMAC", code) + } + } +} + +func TestMessageAuthenticator_ValidateTampered(t *testing.T) { + secret := []byte("s3cr3t") + wire := encodeWithMessageAuthenticator(t, radius.CodeAccessRequest, secret) + if err := SignMessageAuthenticator(wire, secret); err != nil { + t.Fatal(err) + } + + // Flip a bit in the packet body (the User-Name value). + tampered := make([]byte, len(wire)) + copy(tampered, wire) + tampered[22] ^= 0x01 + if err := ValidateMessageAuthenticator(tampered, secret); !errors.Is(err, ErrInvalidMessageAuthenticator) { + t.Fatalf("got err %v; expected ErrInvalidMessageAuthenticator", err) + } + + // A different secret must not validate. + if err := ValidateMessageAuthenticator(wire, []byte("other")); !errors.Is(err, ErrInvalidMessageAuthenticator) { + t.Fatalf("got err %v; expected ErrInvalidMessageAuthenticator", err) + } +} + +func TestMessageAuthenticator_Missing(t *testing.T) { + secret := []byte("s3cr3t") + + p := radius.New(radius.CodeAccessRequest, secret) + p.Add(radius.Type(1), radius.Attribute("user")) + wire, err := p.Encode() + if err != nil { + t.Fatal(err) + } + + if err := ValidateMessageAuthenticator(wire, secret); !errors.Is(err, ErrNoMessageAuthenticator) { + t.Fatalf("validate got err %v; expected ErrNoMessageAuthenticator", err) + } + if err := SignMessageAuthenticator(wire, secret); !errors.Is(err, ErrNoMessageAuthenticator) { + t.Fatalf("sign got err %v; expected ErrNoMessageAuthenticator", err) + } +} + +func TestMessageAuthenticator_Malformed(t *testing.T) { + if err := ValidateMessageAuthenticator([]byte{0x01, 0x02}, []byte("x")); err == nil { + t.Fatal("expected error for short packet") + } +}