From abe66e60d04382e38d27e6df4f96bce331c46bab Mon Sep 17 00:00:00 2001 From: Dominik Rosiek Date: Wed, 15 Jul 2026 14:19:14 +0200 Subject: [PATCH 1/2] feat: gate DIGEST-MD5 and NTLM behind requirefips build tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DIGEST-MD5 (MD5/MD4) and NTLM (MD4, HMAC-MD5, RC4, DES) use algorithms that are not FIPS 140-2/3 approved and cannot be made compliant — the protocols are inherently non-FIPS by design. Under -tags requirefips, both mechanisms are replaced by stubs that return a clear error directing users to GSSAPIBind (Kerberos) or SimpleBind over TLS. This physically removes go-ntlmssp and the direct x/crypto/md4 and crypto/md5 imports from the binary, satisfying the strict FedRAMP High requirement that non-FIPS crypto must be absent from the binary, not merely unreachable at runtime. Non-FIPS builds are unchanged. A fips140.Enabled() runtime guard is also added as a defence-in-depth safety net for binaries built without the tag but running under GOFIPS140. Known limitation: gokrb5 pulls in x/crypto/md4 and crypto/rc4 for its RC4-HMAC Kerberos enctype support regardless of build tags. This will be addressed in a separate upstream or Elastic fork PR. Co-Authored-By: Claude Sonnet 4.6 --- v3/bind.go | 528 +------------------------------------ v3/bind_digest_md5.go | 325 +++++++++++++++++++++++ v3/bind_digest_md5_fips.go | 41 +++ v3/bind_digest_md5_test.go | 60 +++++ v3/bind_ntlm.go | 231 ++++++++++++++++ v3/bind_ntlm_fips.go | 66 +++++ v3/bind_test.go | 51 ---- 7 files changed, 724 insertions(+), 578 deletions(-) create mode 100644 v3/bind_digest_md5.go create mode 100644 v3/bind_digest_md5_fips.go create mode 100644 v3/bind_digest_md5_test.go create mode 100644 v3/bind_ntlm.go create mode 100644 v3/bind_ntlm_fips.go diff --git a/v3/bind.go b/v3/bind.go index c3ee84e3..bb911358 100644 --- a/v3/bind.go +++ b/v3/bind.go @@ -1,21 +1,11 @@ package ldap import ( - "bytes" - "crypto/md5" - "crypto/rand" - "encoding/binary" - "encoding/hex" - enchex "encoding/hex" "errors" "fmt" "io/ioutil" - "strings" - "unicode/utf16" - "github.com/Azure/go-ntlmssp" ber "github.com/go-asn1-ber/asn1-ber" - "golang.org/x/crypto/md4" //nolint:staticcheck ) // SimpleBindRequest represents a username/password bind operation @@ -126,310 +116,6 @@ func (l *Conn) UnauthenticatedBind(username string) error { return err } -// DigestMD5BindRequest represents a digest-md5 bind operation -type DigestMD5BindRequest struct { - Host string - // Username is the name of the Directory object that the client wishes to bind as - Username string - // Password is the credentials to bind with - Password string - // Controls are optional controls to send with the bind request - Controls []Control -} - -func (req *DigestMD5BindRequest) appendTo(envelope *ber.Packet) error { - request := ber.Encode(ber.ClassApplication, ber.TypeConstructed, ApplicationBindRequest, nil, "Bind Request") - request.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, 3, "Version")) - request.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "", "User Name")) - - auth := ber.Encode(ber.ClassContext, ber.TypeConstructed, 3, "", "authentication") - auth.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "DIGEST-MD5", "SASL Mech")) - request.AppendChild(auth) - envelope.AppendChild(request) - if len(req.Controls) > 0 { - envelope.AppendChild(encodeControls(req.Controls)) - } - return nil -} - -// DigestMD5BindResult contains the response from the server -type DigestMD5BindResult struct { - Controls []Control -} - -// MD5Bind performs a digest-md5 bind with the given host, username and password. -func (l *Conn) MD5Bind(host, username, password string) error { - req := &DigestMD5BindRequest{ - Host: host, - Username: username, - Password: password, - } - _, err := l.DigestMD5Bind(req) - return err -} - -// DigestMD5Bind performs the digest-md5 bind operation defined in the given request -func (l *Conn) DigestMD5Bind(digestMD5BindRequest *DigestMD5BindRequest) (*DigestMD5BindResult, error) { - if digestMD5BindRequest.Password == "" { - return nil, NewError(ErrorEmptyPassword, errors.New("ldap: empty password not allowed by the client")) - } - - msgCtx, err := l.doRequest(digestMD5BindRequest) - if err != nil { - return nil, err - } - defer l.finishMessage(msgCtx) - - packet, err := l.readPacket(msgCtx) - if err != nil { - return nil, err - } - l.Debug.Printf("%d: got response %p", msgCtx.id, packet) - if l.Debug { - if err = addLDAPDescriptions(packet); err != nil { - return nil, err - } - ber.PrintPacket(packet) - } - - result := &DigestMD5BindResult{ - Controls: make([]Control, 0), - } - var params map[string]string - if len(packet.Children) == 2 { - if len(packet.Children[1].Children) == 4 { - child := packet.Children[1].Children[0] - if child.Tag != ber.TagEnumerated { - return result, GetLDAPError(packet) - } - if child.Value.(int64) != 14 { - return result, GetLDAPError(packet) - } - child = packet.Children[1].Children[3] - if child.Tag != ber.TagObjectDescriptor { - return result, GetLDAPError(packet) - } - if child.Data == nil { - return result, GetLDAPError(packet) - } - data, _ := ioutil.ReadAll(child.Data) - params, err = parseParams(string(data)) - if err != nil { - return result, fmt.Errorf("parsing digest-challenge: %s", err) - } - } - } - - if len(params) > 0 { - resp, err := computeResponse( - params, - "ldap/"+strings.ToLower(digestMD5BindRequest.Host), - digestMD5BindRequest.Username, - digestMD5BindRequest.Password, - ) - if err != nil { - return nil, fmt.Errorf("compute digest-md5 response: %s", err) - } - packet = ber.Encode(ber.ClassUniversal, ber.TypeConstructed, ber.TagSequence, nil, "LDAP Request") - packet.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, l.nextMessageID(), "MessageID")) - - request := ber.Encode(ber.ClassApplication, ber.TypeConstructed, ApplicationBindRequest, nil, "Bind Request") - request.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, 3, "Version")) - request.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "", "User Name")) - - auth := ber.Encode(ber.ClassContext, ber.TypeConstructed, 3, "", "authentication") - auth.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "DIGEST-MD5", "SASL Mech")) - auth.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, resp, "Credentials")) - request.AppendChild(auth) - packet.AppendChild(request) - msgCtx, err = l.sendMessage(packet) - if err != nil { - return nil, fmt.Errorf("send message: %s", err) - } - defer l.finishMessage(msgCtx) - packetResponse, ok := <-msgCtx.responses - if !ok { - return nil, NewError(ErrorNetwork, errors.New("ldap: response channel closed")) - } - packet, err = packetResponse.ReadPacket() - l.Debug.Printf("%d: got response %p", msgCtx.id, packet) - if err != nil { - return nil, fmt.Errorf("read packet: %s", err) - } - - if len(packet.Children) == 2 { - response := packet.Children[1] - if response == nil { - return result, GetLDAPError(packet) - } - if response.ClassType == ber.ClassApplication && response.TagType == ber.TypeConstructed && len(response.Children) >= 3 { - if ber.Type(response.Children[0].Tag) == ber.Type(ber.TagInteger) || ber.Type(response.Children[0].Tag) == ber.Type(ber.TagEnumerated) { - resultCode := uint16(response.Children[0].Value.(int64)) - if resultCode == 14 { - msgCtx, err := l.doRequest(digestMD5BindRequest) - if err != nil { - return nil, err - } - defer l.finishMessage(msgCtx) - packetResponse, ok := <-msgCtx.responses - if !ok { - return nil, NewError(ErrorNetwork, errors.New("ldap: response channel closed")) - } - packet, err = packetResponse.ReadPacket() - l.Debug.Printf("%d: got response %p", msgCtx.id, packet) - if err != nil { - return nil, fmt.Errorf("read packet: %s", err) - } - } - } - } - } - } - - err = GetLDAPError(packet) - return result, err -} - -func parseParams(str string) (map[string]string, error) { - m := make(map[string]string) - var key, value string - var state int - var escaped bool - for i := 0; i <= len(str); i++ { - switch state { - case 0: // reading key - if i == len(str) { - return nil, fmt.Errorf("syntax error on %d", i) - } - // The digest-challenge is an RFC 2068 #rule (RFC 2831 section 2.1.1), - // which permits optional linear whitespace around the comma directive - // separators. Directive names are tokens that never contain - // whitespace, so skip it here; otherwise a directive following - // "..., name" is keyed with a leading space and the lookups in - // computeResponse (realm, nonce, authzid) miss it. - if str[i] == ' ' || str[i] == '\t' { - continue - } - if str[i] != '=' { - key += string(str[i]) - continue - } - state = 1 - case 1: // reading value - if i == len(str) { - m[key] = value - break - } - // Linear whitespace outside a quoted string is not part of the - // value: an unquoted value is a token and a quoted value's content - // is read in the quoted state below. Skipping it lets a challenge - // using the whitespace the #rule allows (e.g. `nonce="n" , qop=auth`) - // parse the same as the unspaced form. - if str[i] == ' ' || str[i] == '\t' { - continue - } - switch str[i] { - case ',': - m[key] = value - state = 0 - key = "" - value = "" - case '"': - if value != "" { - return nil, fmt.Errorf("syntax error on %d", i) - } - state = 2 - default: - value += string(str[i]) - } - case 2: // inside quotes - if i == len(str) { - return nil, fmt.Errorf("syntax error on %d", i) - } - switch { - case escaped: - // RFC 2831 section 7.1 quoted-pair: a backslash escapes the - // following character, so the next byte is taken literally - // (this is how a server sends a literal " or \ in a realm or - // nonce). - value += string(str[i]) - escaped = false - case str[i] == '\\': - escaped = true - case str[i] == '"': - state = 1 - default: - value += string(str[i]) - } - } - } - return m, nil -} - -func computeResponse(params map[string]string, uri, username, password string) (string, error) { - nc := "00000001" - qop := "auth" - rb, err := randomBytes(16) - if err != nil { - return "", err - } - cnonce := enchex.EncodeToString(rb) - x := username + ":" + params["realm"] + ":" + password - y := md5Hash([]byte(x)) - - a1 := bytes.NewBuffer(y) - a1.WriteString(":" + params["nonce"] + ":" + cnonce) - if len(params["authzid"]) > 0 { - a1.WriteString(":" + params["authzid"]) - } - a2 := bytes.NewBuffer([]byte("AUTHENTICATE")) - a2.WriteString(":" + uri) - ha1 := enchex.EncodeToString(md5Hash(a1.Bytes())) - ha2 := enchex.EncodeToString(md5Hash(a2.Bytes())) - - kd := ha1 - kd += ":" + params["nonce"] - kd += ":" + nc - kd += ":" + cnonce - kd += ":" + qop - kd += ":" + ha2 - resp := enchex.EncodeToString(md5Hash([]byte(kd))) - return fmt.Sprintf( - `username="%s",realm="%s",nonce="%s",cnonce="%s",nc=00000001,qop=%s,digest-uri="%s",response=%s`, - quotedStringEscape(username), - quotedStringEscape(params["realm"]), - quotedStringEscape(params["nonce"]), - cnonce, - qop, - quotedStringEscape(uri), - resp, - ), nil -} - -// quotedStringEscape escapes the two characters that may not appear unescaped -// inside a DIGEST-MD5 quoted string per RFC 2831 section 7.1: the backslash -// and the double quote. The backslash is replaced first so the quotes escaped -// afterwards are not doubled. -func quotedStringEscape(s string) string { - s = strings.ReplaceAll(s, `\`, `\\`) - s = strings.ReplaceAll(s, `"`, `\"`) - return s -} - -func md5Hash(b []byte) []byte { - hasher := md5.New() - hasher.Write(b) - return hasher.Sum(nil) -} - -func randomBytes(length int) ([]byte, error) { - b := make([]byte, length) - if _, err := rand.Read(b); err != nil { - return nil, err - } - return b, nil -} - var externalBindRequest = requestFunc(func(envelope *ber.Packet) error { pkt := ber.Encode(ber.ClassApplication, ber.TypeConstructed, ApplicationBindRequest, nil, "Bind Request") pkt.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, 3, "Version")) @@ -466,218 +152,6 @@ func (l *Conn) ExternalBind() error { return GetLDAPError(packet) } -// NTLMBind performs an NTLMSSP bind leveraging https://github.com/Azure/go-ntlmssp - -// NTLMBindRequest represents an NTLMSSP bind operation -type NTLMBindRequest struct { - // Domain is the AD Domain to authenticate too. If not specified, it will be grabbed from the NTLMSSP Challenge - Domain string - // Username is the name of the Directory object that the client wishes to bind as - Username string - // Password is the credentials to bind with - Password string - // AllowEmptyPassword sets whether the client allows binding with an empty password - // (normally used for unauthenticated bind). - AllowEmptyPassword bool - // Hash is the hex NTLM hash to bind with. Password or hash must be provided - Hash string - // Controls are optional controls to send with the bind request - Controls []Control - // Negotiator allows to specify a custom NTLM negotiator. - Negotiator NTLMNegotiator -} - -// NTLMNegotiator is an abstraction of an NTLM implementation that produces and -// processes NTLM binary tokens. -type NTLMNegotiator interface { - Negotiate(domain string, workstation string) ([]byte, error) - ChallengeResponse(challenge []byte, username string, hash string) ([]byte, error) -} - -func (req *NTLMBindRequest) appendTo(envelope *ber.Packet) (err error) { - request := ber.Encode(ber.ClassApplication, ber.TypeConstructed, ApplicationBindRequest, nil, "Bind Request") - request.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, 3, "Version")) - request.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "", "User Name")) - - var negMessage []byte - - // generate an NTLMSSP Negotiation message for the specified domain (it can be blank) - switch { - case req.Negotiator == nil: - negMessage, err = ntlmssp.NewNegotiateMessage(req.Domain, "") - if err != nil { - return fmt.Errorf("create NTLM negotiate message: %s", err) - } - default: - negMessage, err = req.Negotiator.Negotiate(req.Domain, "") - if err != nil { - return fmt.Errorf("create NTLM negotiate message with custom negotiator: %s", err) - } - } - - // append the generated NTLMSSP message as a TagEnumerated BER value - auth := ber.Encode(ber.ClassContext, ber.TypePrimitive, ber.TagEnumerated, negMessage, "authentication") - request.AppendChild(auth) - envelope.AppendChild(request) - if len(req.Controls) > 0 { - envelope.AppendChild(encodeControls(req.Controls)) - } - return nil -} - -// NTLMBindResult contains the response from the server -type NTLMBindResult struct { - Controls []Control -} - -// NTLMBind performs an NTLMSSP Bind with the given domain, username and password -func (l *Conn) NTLMBind(domain, username, password string) error { - req := &NTLMBindRequest{ - Domain: domain, - Username: username, - Password: password, - } - _, err := l.NTLMChallengeBind(req) - return err -} - -// NTLMUnauthenticatedBind performs an bind with an empty password. -// -// A username is required. The anonymous bind is not (yet) supported by the go-ntlmssp library (https://github.com/Azure/go-ntlmssp/blob/819c794454d067543bc61d29f61fef4b3c3df62c/authenticate_message.go#L87) -// -// See https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-nlmp/b38c36ed-2804-4868-a9ff-8dd3182128e4 part 3.2.5.1.2 -func (l *Conn) NTLMUnauthenticatedBind(domain, username string) error { - req := &NTLMBindRequest{ - Domain: domain, - Username: username, - Password: "", - AllowEmptyPassword: true, - } - _, err := l.NTLMChallengeBind(req) - return err -} - -// NTLMBindWithHash performs an NTLM Bind with an NTLM hash instead of plaintext password (pass-the-hash) -func (l *Conn) NTLMBindWithHash(domain, username, hash string) error { - req := &NTLMBindRequest{ - Domain: domain, - Username: username, - Hash: hash, - } - _, err := l.NTLMChallengeBind(req) - return err -} - -// NTLMChallengeBind performs the NTLMSSP bind operation defined in the given request -func (l *Conn) NTLMChallengeBind(ntlmBindRequest *NTLMBindRequest) (*NTLMBindResult, error) { - if !ntlmBindRequest.AllowEmptyPassword && ntlmBindRequest.Password == "" && ntlmBindRequest.Hash == "" { - return nil, NewError(ErrorEmptyPassword, errors.New("ldap: empty password not allowed by the client")) - } - - msgCtx, err := l.doRequest(ntlmBindRequest) - if err != nil { - return nil, err - } - defer l.finishMessage(msgCtx) - packet, err := l.readPacket(msgCtx) - if err != nil { - return nil, err - } - l.Debug.Printf("%d: got response %p", msgCtx.id, packet) - if l.Debug { - if err = addLDAPDescriptions(packet); err != nil { - return nil, err - } - ber.PrintPacket(packet) - } - result := &NTLMBindResult{ - Controls: make([]Control, 0), - } - var ntlmsspChallenge []byte - - // now find the NTLM Response Message - if len(packet.Children) == 2 { - if len(packet.Children[1].Children) == 3 { - child := packet.Children[1].Children[1] - ntlmsspChallenge = child.ByteValue - // Check to make sure we got the right message. It will always start with NTLMSSP - if len(ntlmsspChallenge) < 7 || !bytes.Equal(ntlmsspChallenge[:7], []byte("NTLMSSP")) { - return result, GetLDAPError(packet) - } - l.Debug.Printf("%d: found ntlmssp challenge", msgCtx.id) - } - } - if ntlmsspChallenge != nil { - var err error - var responseMessage []byte - - switch { - case ntlmBindRequest.Hash == "" && ntlmBindRequest.Password == "" && !ntlmBindRequest.AllowEmptyPassword: - err = fmt.Errorf("need a password or hash to generate reply") - case ntlmBindRequest.Negotiator == nil && ntlmBindRequest.Hash != "": - responseMessage, err = ntlmssp.ProcessChallengeWithHash(ntlmsspChallenge, ntlmBindRequest.Username, ntlmBindRequest.Hash) - case ntlmBindRequest.Negotiator == nil && (ntlmBindRequest.Password != "" || ntlmBindRequest.AllowEmptyPassword): - // generate a response message to the challenge with the given Username/Password if password is provided - _, _, domainNeeded := ntlmssp.GetDomain(ntlmBindRequest.Username) - responseMessage, err = ntlmssp.ProcessChallenge(ntlmsspChallenge, ntlmBindRequest.Username, ntlmBindRequest.Password, domainNeeded) - default: - hash := ntlmBindRequest.Hash - if len(hash) == 0 { - hash = ntHash(ntlmBindRequest.Password) - } - - responseMessage, err = ntlmBindRequest.Negotiator.ChallengeResponse(ntlmsspChallenge, ntlmBindRequest.Username, hash) - } - - if err != nil { - return result, fmt.Errorf("process NTLM challenge: %s", err) - } - - packet = ber.Encode(ber.ClassUniversal, ber.TypeConstructed, ber.TagSequence, nil, "LDAP Request") - packet.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, l.nextMessageID(), "MessageID")) - - request := ber.Encode(ber.ClassApplication, ber.TypeConstructed, ApplicationBindRequest, nil, "Bind Request") - request.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, 3, "Version")) - request.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "", "User Name")) - - // append the challenge response message as a TagEmbeddedPDV BER value - auth := ber.Encode(ber.ClassContext, ber.TypePrimitive, ber.TagEmbeddedPDV, responseMessage, "authentication") - - request.AppendChild(auth) - packet.AppendChild(request) - msgCtx, err = l.sendMessage(packet) - if err != nil { - return nil, fmt.Errorf("send message: %s", err) - } - defer l.finishMessage(msgCtx) - packetResponse, ok := <-msgCtx.responses - if !ok { - return nil, NewError(ErrorNetwork, errors.New("ldap: response channel closed")) - } - packet, err = packetResponse.ReadPacket() - l.Debug.Printf("%d: got response %p", msgCtx.id, packet) - if err != nil { - return nil, fmt.Errorf("read packet: %s", err) - } - - } - - err = GetLDAPError(packet) - return result, err -} - -func ntHash(pass string) string { - runes := utf16.Encode([]rune(pass)) - - b := bytes.Buffer{} - _ = binary.Write(&b, binary.LittleEndian, &runes) - - hash := md4.New() - _, _ = hash.Write(b.Bytes()) - - return hex.EncodeToString(hash.Sum(nil)) -} - // GSSAPIClient interface is used as the client-side implementation for the // GSSAPI SASL mechanism. // Interface inspired by GSSAPIClient from golang.org/x/crypto/ssh @@ -739,7 +213,7 @@ func (l *Conn) GSSAPIBindRequest(client GSSAPIClient, req *GSSAPIBindRequest) er return l.GSSAPIBindRequestWithAPOptions(client, req, []int{}) } -// GSSAPIBindRequest performs the GSSAPI SASL bind using the provided GSSAPI client. +// GSSAPIBindRequestWithAPOptions performs the GSSAPI SASL bind using the provided GSSAPI client. func (l *Conn) GSSAPIBindRequestWithAPOptions(client GSSAPIClient, req *GSSAPIBindRequest, APOptions []int) error { //nolint:errcheck defer client.DeleteSecContext() diff --git a/v3/bind_digest_md5.go b/v3/bind_digest_md5.go new file mode 100644 index 00000000..fdb4dcf2 --- /dev/null +++ b/v3/bind_digest_md5.go @@ -0,0 +1,325 @@ +//go:build !requirefips + +package ldap + +import ( + "bytes" + "crypto/fips140" + "crypto/md5" + "crypto/rand" + enchex "encoding/hex" + "errors" + "fmt" + "io/ioutil" + "strings" + + ber "github.com/go-asn1-ber/asn1-ber" +) + +// DigestMD5BindRequest represents a digest-md5 bind operation +type DigestMD5BindRequest struct { + Host string + // Username is the name of the Directory object that the client wishes to bind as + Username string + // Password is the credentials to bind with + Password string + // Controls are optional controls to send with the bind request + Controls []Control +} + +func (req *DigestMD5BindRequest) appendTo(envelope *ber.Packet) error { + request := ber.Encode(ber.ClassApplication, ber.TypeConstructed, ApplicationBindRequest, nil, "Bind Request") + request.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, 3, "Version")) + request.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "", "User Name")) + + auth := ber.Encode(ber.ClassContext, ber.TypeConstructed, 3, "", "authentication") + auth.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "DIGEST-MD5", "SASL Mech")) + request.AppendChild(auth) + envelope.AppendChild(request) + if len(req.Controls) > 0 { + envelope.AppendChild(encodeControls(req.Controls)) + } + return nil +} + +// DigestMD5BindResult contains the response from the server +type DigestMD5BindResult struct { + Controls []Control +} + +// MD5Bind performs a digest-md5 bind with the given host, username and password. +func (l *Conn) MD5Bind(host, username, password string) error { + req := &DigestMD5BindRequest{ + Host: host, + Username: username, + Password: password, + } + _, err := l.DigestMD5Bind(req) + return err +} + +// DigestMD5Bind performs the digest-md5 bind operation defined in the given request +func (l *Conn) DigestMD5Bind(digestMD5BindRequest *DigestMD5BindRequest) (*DigestMD5BindResult, error) { + if fips140.Enabled() { + return nil, errors.New("ldap: DIGEST-MD5 is not available in FIPS mode; use GSSAPIBind (Kerberos) or SimpleBind over TLS") + } + + if digestMD5BindRequest.Password == "" { + return nil, NewError(ErrorEmptyPassword, errors.New("ldap: empty password not allowed by the client")) + } + + msgCtx, err := l.doRequest(digestMD5BindRequest) + if err != nil { + return nil, err + } + defer l.finishMessage(msgCtx) + + packet, err := l.readPacket(msgCtx) + if err != nil { + return nil, err + } + l.Debug.Printf("%d: got response %p", msgCtx.id, packet) + if l.Debug { + if err = addLDAPDescriptions(packet); err != nil { + return nil, err + } + ber.PrintPacket(packet) + } + + result := &DigestMD5BindResult{ + Controls: make([]Control, 0), + } + var params map[string]string + if len(packet.Children) == 2 { + if len(packet.Children[1].Children) == 4 { + child := packet.Children[1].Children[0] + if child.Tag != ber.TagEnumerated { + return result, GetLDAPError(packet) + } + if child.Value.(int64) != 14 { + return result, GetLDAPError(packet) + } + child = packet.Children[1].Children[3] + if child.Tag != ber.TagObjectDescriptor { + return result, GetLDAPError(packet) + } + if child.Data == nil { + return result, GetLDAPError(packet) + } + data, _ := ioutil.ReadAll(child.Data) + params, err = parseParams(string(data)) + if err != nil { + return result, fmt.Errorf("parsing digest-challenge: %s", err) + } + } + } + + if len(params) > 0 { + resp, err := computeResponse( + params, + "ldap/"+strings.ToLower(digestMD5BindRequest.Host), + digestMD5BindRequest.Username, + digestMD5BindRequest.Password, + ) + if err != nil { + return nil, fmt.Errorf("compute digest-md5 response: %s", err) + } + packet = ber.Encode(ber.ClassUniversal, ber.TypeConstructed, ber.TagSequence, nil, "LDAP Request") + packet.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, l.nextMessageID(), "MessageID")) + + request := ber.Encode(ber.ClassApplication, ber.TypeConstructed, ApplicationBindRequest, nil, "Bind Request") + request.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, 3, "Version")) + request.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "", "User Name")) + + auth := ber.Encode(ber.ClassContext, ber.TypeConstructed, 3, "", "authentication") + auth.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "DIGEST-MD5", "SASL Mech")) + auth.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, resp, "Credentials")) + request.AppendChild(auth) + packet.AppendChild(request) + msgCtx, err = l.sendMessage(packet) + if err != nil { + return nil, fmt.Errorf("send message: %s", err) + } + defer l.finishMessage(msgCtx) + packetResponse, ok := <-msgCtx.responses + if !ok { + return nil, NewError(ErrorNetwork, errors.New("ldap: response channel closed")) + } + packet, err = packetResponse.ReadPacket() + l.Debug.Printf("%d: got response %p", msgCtx.id, packet) + if err != nil { + return nil, fmt.Errorf("read packet: %s", err) + } + + if len(packet.Children) == 2 { + response := packet.Children[1] + if response == nil { + return result, GetLDAPError(packet) + } + if response.ClassType == ber.ClassApplication && response.TagType == ber.TypeConstructed && len(response.Children) >= 3 { + if ber.Type(response.Children[0].Tag) == ber.Type(ber.TagInteger) || ber.Type(response.Children[0].Tag) == ber.Type(ber.TagEnumerated) { + resultCode := uint16(response.Children[0].Value.(int64)) + if resultCode == 14 { + msgCtx, err := l.doRequest(digestMD5BindRequest) + if err != nil { + return nil, err + } + defer l.finishMessage(msgCtx) + packetResponse, ok := <-msgCtx.responses + if !ok { + return nil, NewError(ErrorNetwork, errors.New("ldap: response channel closed")) + } + packet, err = packetResponse.ReadPacket() + l.Debug.Printf("%d: got response %p", msgCtx.id, packet) + if err != nil { + return nil, fmt.Errorf("read packet: %s", err) + } + } + } + } + } + } + + err = GetLDAPError(packet) + return result, err +} + +func parseParams(str string) (map[string]string, error) { + m := make(map[string]string) + var key, value string + var state int + var escaped bool + for i := 0; i <= len(str); i++ { + switch state { + case 0: // reading key + if i == len(str) { + return nil, fmt.Errorf("syntax error on %d", i) + } + // The digest-challenge is an RFC 2068 #rule (RFC 2831 section 2.1.1), + // which permits optional linear whitespace around the comma directive + // separators. Directive names are tokens that never contain + // whitespace, so skip it here; otherwise a directive following + // "..., name" is keyed with a leading space and the lookups in + // computeResponse (realm, nonce, authzid) miss it. + if str[i] == ' ' || str[i] == '\t' { + continue + } + if str[i] != '=' { + key += string(str[i]) + continue + } + state = 1 + case 1: // reading value + if i == len(str) { + m[key] = value + break + } + // Linear whitespace outside a quoted string is not part of the + // value: an unquoted value is a token and a quoted value's content + // is read in the quoted state below. Skipping it lets a challenge + // using the whitespace the #rule allows (e.g. `nonce="n" , qop=auth`) + // parse the same as the unspaced form. + if str[i] == ' ' || str[i] == '\t' { + continue + } + switch str[i] { + case ',': + m[key] = value + state = 0 + key = "" + value = "" + case '"': + if value != "" { + return nil, fmt.Errorf("syntax error on %d", i) + } + state = 2 + default: + value += string(str[i]) + } + case 2: // inside quotes + if i == len(str) { + return nil, fmt.Errorf("syntax error on %d", i) + } + switch { + case escaped: + // RFC 2831 section 7.1 quoted-pair: a backslash escapes the + // following character, so the next byte is taken literally + // (this is how a server sends a literal " or \ in a realm or + // nonce). + value += string(str[i]) + escaped = false + case str[i] == '\\': + escaped = true + case str[i] == '"': + state = 1 + default: + value += string(str[i]) + } + } + } + return m, nil +} + +func computeResponse(params map[string]string, uri, username, password string) (string, error) { + nc := "00000001" + qop := "auth" + rb, err := randomBytes(16) + if err != nil { + return "", err + } + cnonce := enchex.EncodeToString(rb) + x := username + ":" + params["realm"] + ":" + password + y := md5Hash([]byte(x)) + + a1 := bytes.NewBuffer(y) + a1.WriteString(":" + params["nonce"] + ":" + cnonce) + if len(params["authzid"]) > 0 { + a1.WriteString(":" + params["authzid"]) + } + a2 := bytes.NewBuffer([]byte("AUTHENTICATE")) + a2.WriteString(":" + uri) + ha1 := enchex.EncodeToString(md5Hash(a1.Bytes())) + ha2 := enchex.EncodeToString(md5Hash(a2.Bytes())) + + kd := ha1 + kd += ":" + params["nonce"] + kd += ":" + nc + kd += ":" + cnonce + kd += ":" + qop + kd += ":" + ha2 + resp := enchex.EncodeToString(md5Hash([]byte(kd))) + return fmt.Sprintf( + `username="%s",realm="%s",nonce="%s",cnonce="%s",nc=00000001,qop=%s,digest-uri="%s",response=%s`, + quotedStringEscape(username), + quotedStringEscape(params["realm"]), + quotedStringEscape(params["nonce"]), + cnonce, + qop, + quotedStringEscape(uri), + resp, + ), nil +} + +// quotedStringEscape escapes the two characters that may not appear unescaped +// inside a DIGEST-MD5 quoted string per RFC 2831 section 7.1: the backslash +// and the double quote. The backslash is replaced first so the quotes escaped +// afterwards are not doubled. +func quotedStringEscape(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `"`, `\"`) + return s +} + +func md5Hash(b []byte) []byte { + hasher := md5.New() + hasher.Write(b) + return hasher.Sum(nil) +} + +func randomBytes(length int) ([]byte, error) { + b := make([]byte, length) + if _, err := rand.Read(b); err != nil { + return nil, err + } + return b, nil +} diff --git a/v3/bind_digest_md5_fips.go b/v3/bind_digest_md5_fips.go new file mode 100644 index 00000000..3408cecc --- /dev/null +++ b/v3/bind_digest_md5_fips.go @@ -0,0 +1,41 @@ +//go:build requirefips + +package ldap + +import ( + "errors" + + ber "github.com/go-asn1-ber/asn1-ber" +) + +var errFIPSDigestMD5 = errors.New("ldap: DIGEST-MD5 is not available in FIPS mode; use GSSAPIBind (Kerberos) or SimpleBind over TLS") + +// DigestMD5BindRequest represents a digest-md5 bind operation +type DigestMD5BindRequest struct { + Host string + // Username is the name of the Directory object that the client wishes to bind as + Username string + // Password is the credentials to bind with + Password string + // Controls are optional controls to send with the bind request + Controls []Control +} + +func (req *DigestMD5BindRequest) appendTo(_ *ber.Packet) error { + return errFIPSDigestMD5 +} + +// DigestMD5BindResult contains the response from the server +type DigestMD5BindResult struct { + Controls []Control +} + +// MD5Bind is not available in FIPS mode. Use GSSAPIBind (Kerberos) or SimpleBind over TLS. +func (l *Conn) MD5Bind(host, username, password string) error { + return errFIPSDigestMD5 +} + +// DigestMD5Bind is not available in FIPS mode. Use GSSAPIBind (Kerberos) or SimpleBind over TLS. +func (l *Conn) DigestMD5Bind(_ *DigestMD5BindRequest) (*DigestMD5BindResult, error) { + return nil, errFIPSDigestMD5 +} diff --git a/v3/bind_digest_md5_test.go b/v3/bind_digest_md5_test.go new file mode 100644 index 00000000..51f09cf4 --- /dev/null +++ b/v3/bind_digest_md5_test.go @@ -0,0 +1,60 @@ +//go:build !requirefips + +package ldap + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestComputeResponseQuotesSpecialChars(t *testing.T) { + // A username carrying a double quote or backslash must be emitted as a + // properly escaped DIGEST-MD5 quoted string, otherwise it breaks out of + // the username directive and injects further directives into the response. + params := map[string]string{"realm": "example.com", "nonce": "abc"} + resp, err := computeResponse(params, "ldap/host", `a"b\c`, "secret") + assert.NoError(t, err) + assert.Contains(t, resp, `username="a\"b\\c"`) +} + +func TestComputeResponseQuotesServerRealm(t *testing.T) { + // realm and nonce come from the server challenge and are echoed back + // inside quoted strings, so they need the same escaping. + params := map[string]string{"realm": `r"x`, "nonce": "abc"} + resp, err := computeResponse(params, "ldap/host", "user", "secret") + assert.NoError(t, err) + assert.Contains(t, resp, `realm="r\"x"`) +} + +func TestParseParamsUnescapesQuotedPair(t *testing.T) { + // The DIGEST-MD5 challenge is sent by the server as comma-separated + // directives whose values are quoted strings. Per RFC 2831 section 7.1 a + // literal double quote or backslash inside such a value is sent as a + // quoted-pair (\" or \\), so the parser has to unescape it. Without that + // the value is truncated at the escaped quote and the bind fails. + params, err := parseParams(`realm="a\"b",nonce="c\\d"`) + assert.NoError(t, err) + assert.Equal(t, `a"b`, params["realm"]) + assert.Equal(t, `c\d`, params["nonce"]) +} + +func TestParseParamsLinearWhitespace(t *testing.T) { + // The DIGEST-MD5 challenge is an RFC 2068 #rule (RFC 2831 section 2.1.1), + // so a conforming server may put optional linear whitespace around the + // comma directive separators. Every directive after the first must still be + // keyed by its name; otherwise the leading space makes realm/nonce lookups + // in computeResponse return empty and the bind digest is computed over the + // wrong parameters. + params, err := parseParams(`realm="example.com", nonce="abc123" , qop=auth`) + assert.NoError(t, err) + assert.Equal(t, "example.com", params["realm"]) + assert.Equal(t, "abc123", params["nonce"]) + assert.Equal(t, "auth", params["qop"]) + + // Whitespace inside a quoted value is still significant and must be kept. + params, err = parseParams(`realm="a b", nonce="c d"`) + assert.NoError(t, err) + assert.Equal(t, "a b", params["realm"]) + assert.Equal(t, "c d", params["nonce"]) +} diff --git a/v3/bind_ntlm.go b/v3/bind_ntlm.go new file mode 100644 index 00000000..4b4faeb6 --- /dev/null +++ b/v3/bind_ntlm.go @@ -0,0 +1,231 @@ +//go:build !requirefips + +package ldap + +import ( + "bytes" + "crypto/fips140" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "unicode/utf16" + + "github.com/Azure/go-ntlmssp" + ber "github.com/go-asn1-ber/asn1-ber" + "golang.org/x/crypto/md4" //nolint:staticcheck +) + +// NTLMBindRequest represents an NTLMSSP bind operation +type NTLMBindRequest struct { + // Domain is the AD Domain to authenticate too. If not specified, it will be grabbed from the NTLMSSP Challenge + Domain string + // Username is the name of the Directory object that the client wishes to bind as + Username string + // Password is the credentials to bind with + Password string + // AllowEmptyPassword sets whether the client allows binding with an empty password + // (normally used for unauthenticated bind). + AllowEmptyPassword bool + // Hash is the hex NTLM hash to bind with. Password or hash must be provided + Hash string + // Controls are optional controls to send with the bind request + Controls []Control + // Negotiator allows to specify a custom NTLM negotiator. + Negotiator NTLMNegotiator +} + +// NTLMNegotiator is an abstraction of an NTLM implementation that produces and +// processes NTLM binary tokens. +type NTLMNegotiator interface { + Negotiate(domain string, workstation string) ([]byte, error) + ChallengeResponse(challenge []byte, username string, hash string) ([]byte, error) +} + +func (req *NTLMBindRequest) appendTo(envelope *ber.Packet) (err error) { + request := ber.Encode(ber.ClassApplication, ber.TypeConstructed, ApplicationBindRequest, nil, "Bind Request") + request.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, 3, "Version")) + request.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "", "User Name")) + + var negMessage []byte + + // generate an NTLMSSP Negotiation message for the specified domain (it can be blank) + switch { + case req.Negotiator == nil: + negMessage, err = ntlmssp.NewNegotiateMessage(req.Domain, "") + if err != nil { + return fmt.Errorf("create NTLM negotiate message: %s", err) + } + default: + negMessage, err = req.Negotiator.Negotiate(req.Domain, "") + if err != nil { + return fmt.Errorf("create NTLM negotiate message with custom negotiator: %s", err) + } + } + + // append the generated NTLMSSP message as a TagEnumerated BER value + auth := ber.Encode(ber.ClassContext, ber.TypePrimitive, ber.TagEnumerated, negMessage, "authentication") + request.AppendChild(auth) + envelope.AppendChild(request) + if len(req.Controls) > 0 { + envelope.AppendChild(encodeControls(req.Controls)) + } + return nil +} + +// NTLMBindResult contains the response from the server +type NTLMBindResult struct { + Controls []Control +} + +// NTLMBind performs an NTLMSSP Bind with the given domain, username and password +func (l *Conn) NTLMBind(domain, username, password string) error { + req := &NTLMBindRequest{ + Domain: domain, + Username: username, + Password: password, + } + _, err := l.NTLMChallengeBind(req) + return err +} + +// NTLMUnauthenticatedBind performs an bind with an empty password. +// +// A username is required. The anonymous bind is not (yet) supported by the go-ntlmssp library (https://github.com/Azure/go-ntlmssp/blob/819c794454d067543bc61d29f61fef4b3c3df62c/authenticate_message.go#L87) +// +// See https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-nlmp/b38c36ed-2804-4868-a9ff-8dd3182128e4 part 3.2.5.1.2 +func (l *Conn) NTLMUnauthenticatedBind(domain, username string) error { + req := &NTLMBindRequest{ + Domain: domain, + Username: username, + Password: "", + AllowEmptyPassword: true, + } + _, err := l.NTLMChallengeBind(req) + return err +} + +// NTLMBindWithHash performs an NTLM Bind with an NTLM hash instead of plaintext password (pass-the-hash) +func (l *Conn) NTLMBindWithHash(domain, username, hash string) error { + req := &NTLMBindRequest{ + Domain: domain, + Username: username, + Hash: hash, + } + _, err := l.NTLMChallengeBind(req) + return err +} + +// NTLMChallengeBind performs the NTLMSSP bind operation defined in the given request +func (l *Conn) NTLMChallengeBind(ntlmBindRequest *NTLMBindRequest) (*NTLMBindResult, error) { + if fips140.Enabled() { + return nil, errors.New("ldap: NTLM bind is not available in FIPS mode; use GSSAPIBind (Kerberos)") + } + + if !ntlmBindRequest.AllowEmptyPassword && ntlmBindRequest.Password == "" && ntlmBindRequest.Hash == "" { + return nil, NewError(ErrorEmptyPassword, errors.New("ldap: empty password not allowed by the client")) + } + + msgCtx, err := l.doRequest(ntlmBindRequest) + if err != nil { + return nil, err + } + defer l.finishMessage(msgCtx) + packet, err := l.readPacket(msgCtx) + if err != nil { + return nil, err + } + l.Debug.Printf("%d: got response %p", msgCtx.id, packet) + if l.Debug { + if err = addLDAPDescriptions(packet); err != nil { + return nil, err + } + ber.PrintPacket(packet) + } + result := &NTLMBindResult{ + Controls: make([]Control, 0), + } + var ntlmsspChallenge []byte + + // now find the NTLM Response Message + if len(packet.Children) == 2 { + if len(packet.Children[1].Children) == 3 { + child := packet.Children[1].Children[1] + ntlmsspChallenge = child.ByteValue + // Check to make sure we got the right message. It will always start with NTLMSSP + if len(ntlmsspChallenge) < 7 || !bytes.Equal(ntlmsspChallenge[:7], []byte("NTLMSSP")) { + return result, GetLDAPError(packet) + } + l.Debug.Printf("%d: found ntlmssp challenge", msgCtx.id) + } + } + if ntlmsspChallenge != nil { + var err error + var responseMessage []byte + + switch { + case ntlmBindRequest.Hash == "" && ntlmBindRequest.Password == "" && !ntlmBindRequest.AllowEmptyPassword: + err = fmt.Errorf("need a password or hash to generate reply") + case ntlmBindRequest.Negotiator == nil && ntlmBindRequest.Hash != "": + responseMessage, err = ntlmssp.ProcessChallengeWithHash(ntlmsspChallenge, ntlmBindRequest.Username, ntlmBindRequest.Hash) + case ntlmBindRequest.Negotiator == nil && (ntlmBindRequest.Password != "" || ntlmBindRequest.AllowEmptyPassword): + // generate a response message to the challenge with the given Username/Password if password is provided + _, _, domainNeeded := ntlmssp.GetDomain(ntlmBindRequest.Username) + responseMessage, err = ntlmssp.ProcessChallenge(ntlmsspChallenge, ntlmBindRequest.Username, ntlmBindRequest.Password, domainNeeded) + default: + hash := ntlmBindRequest.Hash + if len(hash) == 0 { + hash = ntHash(ntlmBindRequest.Password) + } + + responseMessage, err = ntlmBindRequest.Negotiator.ChallengeResponse(ntlmsspChallenge, ntlmBindRequest.Username, hash) + } + + if err != nil { + return result, fmt.Errorf("process NTLM challenge: %s", err) + } + + packet = ber.Encode(ber.ClassUniversal, ber.TypeConstructed, ber.TagSequence, nil, "LDAP Request") + packet.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, l.nextMessageID(), "MessageID")) + + request := ber.Encode(ber.ClassApplication, ber.TypeConstructed, ApplicationBindRequest, nil, "Bind Request") + request.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, 3, "Version")) + request.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "", "User Name")) + + // append the challenge response message as a TagEmbeddedPDV BER value + auth := ber.Encode(ber.ClassContext, ber.TypePrimitive, ber.TagEmbeddedPDV, responseMessage, "authentication") + + request.AppendChild(auth) + packet.AppendChild(request) + msgCtx, err = l.sendMessage(packet) + if err != nil { + return nil, fmt.Errorf("send message: %s", err) + } + defer l.finishMessage(msgCtx) + packetResponse, ok := <-msgCtx.responses + if !ok { + return nil, NewError(ErrorNetwork, errors.New("ldap: response channel closed")) + } + packet, err = packetResponse.ReadPacket() + l.Debug.Printf("%d: got response %p", msgCtx.id, packet) + if err != nil { + return nil, fmt.Errorf("read packet: %s", err) + } + + } + + err = GetLDAPError(packet) + return result, err +} + +func ntHash(pass string) string { + runes := utf16.Encode([]rune(pass)) + + b := bytes.Buffer{} + _ = binary.Write(&b, binary.LittleEndian, &runes) + + hash := md4.New() + _, _ = hash.Write(b.Bytes()) + + return hex.EncodeToString(hash.Sum(nil)) +} diff --git a/v3/bind_ntlm_fips.go b/v3/bind_ntlm_fips.go new file mode 100644 index 00000000..43733c72 --- /dev/null +++ b/v3/bind_ntlm_fips.go @@ -0,0 +1,66 @@ +//go:build requirefips + +package ldap + +import ( + "errors" + + ber "github.com/go-asn1-ber/asn1-ber" +) + +var errFIPSNTLM = errors.New("ldap: NTLM bind is not available in FIPS mode; use GSSAPIBind (Kerberos)") + +// NTLMBindRequest represents an NTLMSSP bind operation +type NTLMBindRequest struct { + // Domain is the AD Domain to authenticate too. If not specified, it will be grabbed from the NTLMSSP Challenge + Domain string + // Username is the name of the Directory object that the client wishes to bind as + Username string + // Password is the credentials to bind with + Password string + // AllowEmptyPassword sets whether the client allows binding with an empty password + // (normally used for unauthenticated bind). + AllowEmptyPassword bool + // Hash is the hex NTLM hash to bind with. Password or hash must be provided + Hash string + // Controls are optional controls to send with the bind request + Controls []Control + // Negotiator allows to specify a custom NTLM negotiator. + Negotiator NTLMNegotiator +} + +// NTLMNegotiator is an abstraction of an NTLM implementation that produces and +// processes NTLM binary tokens. +type NTLMNegotiator interface { + Negotiate(domain string, workstation string) ([]byte, error) + ChallengeResponse(challenge []byte, username string, hash string) ([]byte, error) +} + +func (req *NTLMBindRequest) appendTo(_ *ber.Packet) error { + return errFIPSNTLM +} + +// NTLMBindResult contains the response from the server +type NTLMBindResult struct { + Controls []Control +} + +// NTLMBind is not available in FIPS mode. Use GSSAPIBind (Kerberos) instead. +func (l *Conn) NTLMBind(domain, username, password string) error { + return errFIPSNTLM +} + +// NTLMUnauthenticatedBind is not available in FIPS mode. Use GSSAPIBind (Kerberos) instead. +func (l *Conn) NTLMUnauthenticatedBind(domain, username string) error { + return errFIPSNTLM +} + +// NTLMBindWithHash is not available in FIPS mode. Use GSSAPIBind (Kerberos) instead. +func (l *Conn) NTLMBindWithHash(domain, username, hash string) error { + return errFIPSNTLM +} + +// NTLMChallengeBind is not available in FIPS mode. Use GSSAPIBind (Kerberos) instead. +func (l *Conn) NTLMChallengeBind(_ *NTLMBindRequest) (*NTLMBindResult, error) { + return nil, errFIPSNTLM +} diff --git a/v3/bind_test.go b/v3/bind_test.go index 2289069e..b764149d 100644 --- a/v3/bind_test.go +++ b/v3/bind_test.go @@ -55,57 +55,6 @@ func TestConn_Bind(t *testing.T) { } } -func TestComputeResponseQuotesSpecialChars(t *testing.T) { - // A username carrying a double quote or backslash must be emitted as a - // properly escaped DIGEST-MD5 quoted string, otherwise it breaks out of - // the username directive and injects further directives into the response. - params := map[string]string{"realm": "example.com", "nonce": "abc"} - resp, err := computeResponse(params, "ldap/host", `a"b\c`, "secret") - assert.NoError(t, err) - assert.Contains(t, resp, `username="a\"b\\c"`) -} - -func TestComputeResponseQuotesServerRealm(t *testing.T) { - // realm and nonce come from the server challenge and are echoed back - // inside quoted strings, so they need the same escaping. - params := map[string]string{"realm": `r"x`, "nonce": "abc"} - resp, err := computeResponse(params, "ldap/host", "user", "secret") - assert.NoError(t, err) - assert.Contains(t, resp, `realm="r\"x"`) -} - -func TestParseParamsUnescapesQuotedPair(t *testing.T) { - // The DIGEST-MD5 challenge is sent by the server as comma-separated - // directives whose values are quoted strings. Per RFC 2831 section 7.1 a - // literal double quote or backslash inside such a value is sent as a - // quoted-pair (\" or \\), so the parser has to unescape it. Without that - // the value is truncated at the escaped quote and the bind fails. - params, err := parseParams(`realm="a\"b",nonce="c\\d"`) - assert.NoError(t, err) - assert.Equal(t, `a"b`, params["realm"]) - assert.Equal(t, `c\d`, params["nonce"]) -} - -func TestParseParamsLinearWhitespace(t *testing.T) { - // The DIGEST-MD5 challenge is an RFC 2068 #rule (RFC 2831 section 2.1.1), - // so a conforming server may put optional linear whitespace around the - // comma directive separators. Every directive after the first must still be - // keyed by its name; otherwise the leading space makes realm/nonce lookups - // in computeResponse return empty and the bind digest is computed over the - // wrong parameters. - params, err := parseParams(`realm="example.com", nonce="abc123" , qop=auth`) - assert.NoError(t, err) - assert.Equal(t, "example.com", params["realm"]) - assert.Equal(t, "abc123", params["nonce"]) - assert.Equal(t, "auth", params["qop"]) - - // Whitespace inside a quoted value is still significant and must be kept. - params, err = parseParams(`realm="a b", nonce="c d"`) - assert.NoError(t, err) - assert.Equal(t, "a b", params["realm"]) - assert.Equal(t, "c d", params["nonce"]) -} - func TestConn_UnauthenticatedBind(t *testing.T) { l, err := getTestConnection(false) if err != nil { From 77a37a216394f23f39d2342773b99209b0828917 Mon Sep 17 00:00:00 2001 From: Dominik Rosiek Date: Wed, 15 Jul 2026 14:35:03 +0200 Subject: [PATCH 2/2] ci: add requirefips build validation and FIPS dependency check Adds two steps to the PR workflow: - build/vet/test with -tags requirefips to catch regressions in the FIPS stubs - dependency graph check that fails if any forbidden non-FIPS package (currently go-ntlmssp) appears in a requirefips build TODO in the dep check: extend the forbidden list to include x/crypto/md4 and x/crypto/rc4 once gokrb5 RC4-HMAC enctype support is gated behind a build tag. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/pr.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d15d84dc..5d71f0f9 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -33,3 +33,29 @@ jobs: go vet . go test -v -cover -race -count=1 . go build . + + - name: Build, Validate, and Test (FIPS) + run: | + cd ${{ matrix.directory }} + go vet -tags requirefips . + go test -tags requirefips -v -count=1 . + go build -tags requirefips . + + - name: Verify FIPS dependency graph + run: | + cd ${{ matrix.directory }} + DEPS=$(go list -tags requirefips -deps ./...) + + # These packages must never appear in a requirefips build — fail if they do. + FORBIDDEN="github.com/Azure/go-ntlmssp" + # TODO: add the following once gokrb5 RC4-HMAC enctype support is gated: + # golang.org/x/crypto/md4 + # golang.org/x/crypto/rc4 + + for pkg in $FORBIDDEN; do + if echo "$DEPS" | grep -qx "$pkg"; then + echo "FAIL: non-FIPS package present in requirefips build: $pkg" + exit 1 + fi + done + echo "FIPS dependency check passed"