From e29f5f17f6e0fcc8ba5afa4b40fb940ba10d6540 Mon Sep 17 00:00:00 2001 From: jettwang Date: Mon, 8 Jun 2026 13:25:59 +0800 Subject: [PATCH] Add CHAP verification helpers to rfc2865 rfc2865 exposed accessors for the CHAP-Password and CHAP-Challenge attributes but no way to verify a CHAP (RFC 1994 / RFC 2865) response against a known password. MS-CHAP and MS-CHAPv2 helpers already exist in rfc2759, but plain CHAP did not, so servers handling equipment that uses standard CHAP (for example some Huawei switches) had to implement the MD5 check themselves. Add CHAPMatch, which checks a raw CHAP-Password value against a password and challenge in constant time, and CHAPVerify, a packet-level convenience that pulls the CHAP-Password and CHAP-Challenge attributes (falling back to the packet Authenticator as the challenge, per RFC 2865, Section 2.2). Closes #96 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rfc2865/chap.go | 57 +++++++++++++++++++++++++++++ rfc2865/chap_test.go | 85 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 rfc2865/chap.go create mode 100644 rfc2865/chap_test.go diff --git a/rfc2865/chap.go b/rfc2865/chap.go new file mode 100644 index 00000000..5be7d193 --- /dev/null +++ b/rfc2865/chap.go @@ -0,0 +1,57 @@ +package rfc2865 + +import ( + "crypto/md5" + "crypto/subtle" + + "layeh.com/radius" +) + +// CHAPMatch reports whether the given CHAP-Password value corresponds to +// password and challenge. +// +// chapPassword must be the raw 17-octet CHAP-Password attribute value: a +// 1-octet CHAP identifier followed by the 16-octet MD5 response, as described +// in RFC 1994 and RFC 2865, Section 2.2. challenge is the CHAP challenge, +// i.e. the CHAP-Challenge attribute value, or the request Authenticator when +// the request does not carry a CHAP-Challenge attribute. +// +// The comparison of the expected and received responses is done in constant +// time. +func CHAPMatch(password, chapPassword, challenge []byte) bool { + if len(chapPassword) != 17 { + return false + } + + h := md5.New() + h.Write(chapPassword[:1]) // CHAP identifier + h.Write(password) + h.Write(challenge) + + var sum [md5.Size]byte + return subtle.ConstantTimeCompare(h.Sum(sum[:0]), chapPassword[1:]) == 1 +} + +// CHAPVerify reports whether password is the correct password for the CHAP +// authentication carried in p. +// +// It combines the CHAP-Password attribute with the CHAP-Challenge attribute. If +// p does not contain a CHAP-Challenge attribute, the packet's Authenticator is +// used as the challenge, as described in RFC 2865, Section 2.2. +// +// CHAPVerify returns false if p does not contain a validly sized CHAP-Password +// attribute. It only handles standard CHAP (MD5); MS-CHAP and MS-CHAPv2 use +// different attributes and algorithms. +func CHAPVerify(p *radius.Packet, password []byte) bool { + chapPassword := CHAPPassword_Get(p) + if len(chapPassword) == 0 { + return false + } + + challenge := CHAPChallenge_Get(p) + if len(challenge) == 0 { + challenge = p.Authenticator[:] + } + + return CHAPMatch(password, chapPassword, challenge) +} diff --git a/rfc2865/chap_test.go b/rfc2865/chap_test.go new file mode 100644 index 00000000..28b23424 --- /dev/null +++ b/rfc2865/chap_test.go @@ -0,0 +1,85 @@ +package rfc2865 + +import ( + "crypto/md5" + "testing" + + "layeh.com/radius" +) + +func makeCHAPPassword(ident byte, password, challenge []byte) []byte { + h := md5.New() + h.Write([]byte{ident}) + h.Write(password) + h.Write(challenge) + + out := make([]byte, 17) + out[0] = ident + copy(out[1:], h.Sum(nil)) + return out +} + +func TestCHAPMatch(t *testing.T) { + password := []byte("hello") + challenge := []byte("0123456789abcdef") + chapPassword := makeCHAPPassword(0x42, password, challenge) + + if !CHAPMatch(password, chapPassword, challenge) { + t.Fatal("CHAPMatch returned false for a valid password") + } + if CHAPMatch([]byte("wrong"), chapPassword, challenge) { + t.Fatal("CHAPMatch returned true for an invalid password") + } + if CHAPMatch(password, chapPassword, []byte("different-chal")) { + t.Fatal("CHAPMatch returned true for a mismatched challenge") + } + if CHAPMatch(password, chapPassword[:16], challenge) { + t.Fatal("CHAPMatch returned true for a malformed CHAP-Password") + } +} + +func TestCHAPVerify_withCHAPChallenge(t *testing.T) { + secret := []byte("secret") + password := []byte("testing123") + challenge := []byte("a-random-challenge") + + p := radius.New(radius.CodeAccessRequest, secret) + if err := CHAPChallenge_Add(p, challenge); err != nil { + t.Fatal(err) + } + if err := CHAPPassword_Add(p, makeCHAPPassword(0x01, password, challenge)); err != nil { + t.Fatal(err) + } + + if !CHAPVerify(p, password) { + t.Fatal("CHAPVerify returned false for a valid password") + } + if CHAPVerify(p, []byte("nope")) { + t.Fatal("CHAPVerify returned true for an invalid password") + } +} + +func TestCHAPVerify_authenticatorAsChallenge(t *testing.T) { + secret := []byte("secret") + password := []byte("testing123") + + p := radius.New(radius.CodeAccessRequest, secret) + // No CHAP-Challenge attribute: the Authenticator is the challenge. + if err := CHAPPassword_Add(p, makeCHAPPassword(0x07, password, p.Authenticator[:])); err != nil { + t.Fatal(err) + } + + if !CHAPVerify(p, password) { + t.Fatal("CHAPVerify returned false for a valid password") + } + if CHAPVerify(p, []byte("nope")) { + t.Fatal("CHAPVerify returned true for an invalid password") + } +} + +func TestCHAPVerify_noCHAPPassword(t *testing.T) { + p := radius.New(radius.CodeAccessRequest, []byte("secret")) + if CHAPVerify(p, []byte("anything")) { + t.Fatal("CHAPVerify returned true for a packet without a CHAP-Password") + } +}