diff --git a/docs/client.md b/docs/client.md index e58b1719..d4fa7e20 100644 --- a/docs/client.md +++ b/docs/client.md @@ -119,8 +119,8 @@ You probably won't need to check the `res` parameter, but it's there if you do. Performs a bind operation against the LDAP server. -The bind API only allows LDAP 'simple' binds (equivalent to HTTP Basic -Authentication) for now. Note that all client APIs can optionally take an array +The bind method allows LDAP 'simple' binds (equivalent to HTTP Basic +Authentication). Note that all client APIs can optionally take an array of `Control` objects. You probably don't need them though... Example: @@ -131,6 +131,30 @@ client.bind('cn=root', 'secret', (err) => { }); ``` +# saslBind +`saslBind(token, controls, callback)` + +Performs a sasl bind operation against the LDAP server. + +Allows you to authenticate using a challange-response mechanism (NTLM/GSSAPI). +This methods accepts a type 1 or type 3 token as a string or Buffer. +Note that all client APIs can optionally take an array +of `Control` objects. You probably don't need them though... + +Example: + +```js +const match = authorizationHeader.match(/^NTLM\s(.+)$/); +const authBase64 = match ? match[1] : ''; +const token = Buffer.from(authBase64, 'base64'); +client.saslBind(token, (err, res) => { + if (res && res.saslChallange) { + console.log(res.saslChallange); + } + assert.ifError(err); +}); +``` + # add `add(dn, entry, controls, callback)` diff --git a/lib/client/client.js b/lib/client/client.js index 7d74aa74..4be60dfb 100644 --- a/lib/client/client.js +++ b/lib/client/client.js @@ -298,6 +298,46 @@ Client.prototype.bind = function bind (name, return this._send(req, [errors.LDAP_SUCCESS], null, callbackWrapper, _bypass) } +/** + * Performs a sasl (NTLM/GSSAPI) authentication against the server. + * + * @param {String} token the token from the authentication http header. + * @param {Control} controls (optional) either a Control or [Control]. + * @param {Function} callback of the form f(err, res). + * @throws {TypeError} on invalid input. + */ +Client.prototype.saslBind = function saslBind (token, + controls, + callback, + _bypass) { + if (typeof (token) !== 'string' && !Buffer.isBuffer(token)) { + throw new TypeError('token (string or Buffer) required') + } + if (typeof (controls) === 'function') { + callback = controls + controls = [] + } else { + controls = validateControls(controls) + } + assert.func(callback, 'callback') + + const req = new BindRequest({ + credentials: token || '', + authentication: 'sasl', + controls: controls + }) + + // Connection errors will be reported to the bind callback too (useful when the LDAP server is not available) + const self = this + function callbackWrapper (err, ret) { + self.removeListener('connectError', callbackWrapper) + callback(err, ret) + } + this.addListener('connectError', callbackWrapper) + + return this._send(req, [errors.LDAP_SUCCESS, errors.LDAP_SASL_BIND_IN_PROGRESS], null, callbackWrapper, _bypass) +} + /** * Compares an attribute/value pair with an entry on the LDAP server. * diff --git a/lib/messages/bind_request.js b/lib/messages/bind_request.js index be5ff4c6..616f8e76 100644 --- a/lib/messages/bind_request.js +++ b/lib/messages/bind_request.js @@ -12,7 +12,12 @@ const Protocol = require('../protocol') const Ber = asn1.Ber const LDAP_BIND_SIMPLE = 'simple' -// var LDAP_BIND_SASL = 'sasl' +const LDAP_BIND_SASL = 'sasl' +const SASL_SEQUENCE = 0xa3 +const SASL_SEQUENCE_TYPE1 = 0x60 +const SASL_SEQUENCE_TYPE3 = 0xa1 +const SASL_TOKENLEN_TYPE1 = 40 +const SASL_TOKENLEN_TYPE3 = 522 /// --- API @@ -48,22 +53,164 @@ BindRequest.prototype._parse = function (ber) { const t = ber.peek() - // TODO add support for SASL et al - if (t !== Ber.Context) { throw new Error('authentication 0x' + t.toString(16) + ' not supported') } - - this.authentication = LDAP_BIND_SIMPLE - this.credentials = ber.readString(Ber.Context) + if (t === SASL_SEQUENCE) { + this.authentication = LDAP_BIND_SASL + ber.readSequence(SASL_SEQUENCE) + this.ldapMechanism = ber.readString(Ber.OctetString) + ber.readSequence(0x04) // ldapCredentials + + const typeSequence = ber.readSequence() // GSSAPI + if (typeSequence === SASL_SEQUENCE_TYPE1) { + this.gssapiOID = ber.readOID(Ber.OID) + ber.readSequence(0xa0) // SPNEGO + ber.readSequence(0x30) + ber.readSequence(0xa0) + ber.readSequence(0x30) + this.spnegoMechType = ber.readOID(Ber.OID) + ber.readSequence(0xa2) + this.credentials = ber.readString(Ber.OctetString, true) + } else if (typeSequence === SASL_SEQUENCE_TYPE3) { + ber.readSequence(0x30) // spnego.negTokenTarg + ber.readSequence(0xa2) + ber.readSequence(0x04) + this.credentials = ber.buffer + this.ntlmsspIdentifier = readByteString(ber, 8) + this.ntlmsspMessageType = read32bitNumber(ber) + this.ntlmsspLanManager = extractDataFromToken(this.credentials, ber) + this.ntlmsspNtlm = extractDataFromToken(this.credentials, ber) + this.ntlmsspDomain = extractDataFromToken(this.credentials, ber) + this.ntlmsspUser = extractDataFromToken(this.credentials, ber) + this.ntlmsspHost = extractDataFromToken(this.credentials, ber) + this.ntlmsspAuthSessionKey = readByteString(ber, 8) + const flag4 = ber.readByte() + const flag3 = ber.readByte() + const flag2 = ber.readByte() + const flag1 = ber.readByte() + this.ntlmsspNegotiateFlags = { + Negotiate56: !!(flag1 & 128), + NegotiateKeyExchange: !!(flag1 & 64), + Negotiate128: !!(flag1 & 32), + Negotiate0x10000000: !!(flag1 & 16), + Negotiate0x08000000: !!(flag1 & 8), + Negotiate0x04000000: !!(flag1 & 4), + NegotiateVersion: !!(flag1 & 2), + Negotiate0x01000000: !!(flag1 & 1), + NegotiateTargetInfo: !!(flag2 & 128), + RequestNonNtSession: !!(flag2 & 64), + Negotiate0x00200000: !!(flag2 & 32), + NegotiateIdentify: !!(flag2 & 16), + NegotiateExtendesSecurity: !!(flag2 & 8), + TargetTypeShare: !!(flag2 & 4), + TargetTypeServer: !!(flag2 & 2), + TargetTypeDomain: !!(flag2 & 1), + NegotiateAlwaysSign: !!(flag3 & 128), + Negotiate0x00004000: !!(flag3 & 64), + NegotiateOemWorkstationSupplied: !!(flag3 & 32), + NegotiateOemDomainSupplied: !!(flag3 & 16), + NegotiateAnonymous: !!(flag3 & 8), + NegotiateNtOnly: !!(flag3 & 4), + NegotiateNtlmKey: !!(flag3 & 2), + Negotiate0x00000100: !!(flag3 & 1), + NegotiateLanManagerKey: !!(flag4 & 128), + NegotiateDatagram: !!(flag4 & 64), + NegotiateSeal: !!(flag4 & 32), + NegotiateSign: !!(flag4 & 16), + Request0x00000008: !!(flag4 & 8), + RequestTarget: !!(flag4 & 4), + TargetOem: !!(flag4 & 2), + TargetUnicode: !!(flag4 & 1) + } + this.ntlmsspVersionMajor = ber.readByte() + this.ntlmsspVersionMinor = ber.readByte() + this.ntlmsspBuildNumber = read16bitNumber(ber) + ber.readByte(); ber.readByte(); ber.readByte() + this.ntlmsspNtlmCurrentRev = ber.readByte() + this.messageIntegrityCode = readByteString(ber, 16) + } else { + console.error('SASL sequence %s found, expected %s (for type1) or %s (for type3) found', + typeSequence.toString(16), SASL_SEQUENCE_TYPE1.toString(16), SASL_SEQUENCE_TYPE3.toString(16)) + } + } else if (t === Ber.Context) { + this.authentication = LDAP_BIND_SIMPLE + this.credentials = ber.readString(Ber.Context) + } else { + throw new Error('authentication 0x' + t.toString(16) + ' not supported') + } return true } +function extractDataFromToken (buf, ber) { + const length = read16bitNumber(ber) + read16bitNumber(ber) // maxlen + const offset = read32bitNumber(ber) + return buf.slice(offset, offset + length).toString('utf16le') +} +function read16bitNumber (ber) { return ber.readByte() + (ber.readByte() << 8) } +function read32bitNumber (ber) { return ber.readByte() + (ber.readByte() << 8) + (ber.readByte() << 16) + (ber.readByte() << 24) } +function readByteString (ber, len) { return String.fromCharCode(...range(0, len - 1).map(function () { return ber.readByte() })) } +function range (start, end) { + return Array.from({ length: end - start + 1 }, (_, i) => i) +} + BindRequest.prototype._toBer = function (ber) { assert.ok(ber) ber.writeInt(this.version) ber.writeString((this.name || '').toString()) - // TODO add support for SASL et al - ber.writeString((this.credentials || ''), Ber.Context) + + if (this.authentication === 'sasl') { + ber.startSequence(SASL_SEQUENCE) + ber.writeString('GSS-SPNEGO', Ber.OctetString) + ber.startSequence(0x04) + + if (this.credentials.length === SASL_TOKENLEN_TYPE1) { + ber.startSequence(SASL_SEQUENCE_TYPE1) + ber.writeOID('1.3.6.1.5.5.2', Ber.OID) // gssapiOID + ber.startSequence(0xa0) // SPNEGO + ber.startSequence(0x30) + ber.startSequence(0xa0) + ber.startSequence(0x30) + ber.writeOID('1.3.6.1.4.1.311.2.2.10', Ber.OID) // spnegoMechType + ber.endSequence() + ber.endSequence() + ber.startSequence(0xa2) + + // spnegoMechToken + if (Buffer.isBuffer(this.credentials)) { + ber.writeBuffer(this.credentials, Ber.OctetString) + } else { + ber.writeString(this.credentials, Ber.OctetString) + } + + ber.endSequence() + ber.endSequence() + ber.endSequence() + ber.endSequence() + } else if (this.credentials.length === SASL_TOKENLEN_TYPE3) { + ber.startSequence(SASL_SEQUENCE_TYPE3) + ber.startSequence(0x30) // spnego.negTokenTarg + ber.startSequence(0xa2) + + if (Buffer.isBuffer(this.credentials)) { + ber.writeBuffer(this.credentials, Ber.OctetString) + } else { + ber.writeString(this.credentials, Ber.OctetString) + } + + ber.endSequence() + ber.endSequence() + ber.endSequence() + } else { + console.error('SASL token neither %d (for type1) nor %d (for type3) bytes long, got: %d', + SASL_TOKENLEN_TYPE1, SASL_TOKENLEN_TYPE3, this.credentials.length) + } + + ber.endSequence() + ber.endSequence() + } else { + ber.writeString((this.credentials || ''), Ber.Context) + } return ber } diff --git a/lib/messages/bind_response.js b/lib/messages/bind_response.js index de4f078e..373ca283 100644 --- a/lib/messages/bind_response.js +++ b/lib/messages/bind_response.js @@ -5,6 +5,7 @@ const util = require('util') const LDAPResult = require('./result') const Protocol = require('../protocol') +const { LDAP_SASL_BIND_IN_PROGRESS } = require('../errors/codes') /// --- API @@ -17,6 +18,26 @@ function BindResponse (options) { } util.inherits(BindResponse, LDAPResult) +BindResponse.prototype._parse = function (ber) { + assert.ok(ber) + + this.status = ber.readEnumeration() + this.matchedDN = ber.readString() + this.errorMessage = ber.readString() + + if (this.status === LDAP_SASL_BIND_IN_PROGRESS) { + readByteString(ber, ber.buffer.length - 230) + this.saslChallange = ber.buffer + } + + return true +} + +function readByteString (ber, len) { return String.fromCharCode(...range(0, len - 1).map(function () { return ber.readByte() })) } +function range (start, end) { + return Array.from({ length: end - start + 1 }, (_, i) => i) +} + /// --- Exports module.exports = BindResponse diff --git a/test/messages/bind_request.test.js b/test/messages/bind_request.test.js index f0639c97..f5282444 100644 --- a/test/messages/bind_request.test.js +++ b/test/messages/bind_request.test.js @@ -31,6 +31,7 @@ test('parse', function (t) { const req = new BindRequest() t.ok(req._parse(new BerReader(ber.buffer))) t.equal(req.version, 3) + t.equal(req.authentication, 'simple') t.equal(req.dn.toString(), 'cn=root') t.equal(req.credentials, 'secret') t.end() @@ -56,3 +57,140 @@ test('toBer', function (t) { t.end() }) + +test('parse sasl type1', function (t) { + // The indentation here is what you'd see in Wireshark + const saslBuffer = Buffer.from( + /* */ '020103' + // ... + '0400a358040a4753' + '532d53504e45474f' + // ...x..GS S-SPNEGO + '044a604806062b06' + '01050502a03e303c' + // ......+. .....>0< + 'a00e300c060a2b06' + '010401823702020a' + // ..0...+. ....7... + 'a22a04284e544c4d' + '5353500001000000' + // .*.(NTLM SSP..... + '078208a200000000' + '0000000000000000' + // ........ ........ + '000000000a00614a' + '0000000f', 'hex') // ......aJ .... + const req = new BindRequest() + t.ok(req._parse(new BerReader(saslBuffer))) + t.equal(req.version, 3) + t.equal(req.authentication, 'sasl') + t.equal(req.ldapMechanism, 'GSS-SPNEGO') + t.equal(req.gssapiOID, '1.3.6.1.5.5.2') + t.equal(req.spnegoMechType, '1.3.6.1.4.1.311.2.2.10') + t.equal(req.credentials.length, 40) + t.end() +}) + +test('toBer sasl type1', function (t) { + const token = '0123456789012345678901234567890123456789' // 40 bytes for Type1 token + const req = new BindRequest({ + messageID: 123, + version: 3, + authentication: 'sasl', + credentials: token + }) + t.ok(req) + + const ldapmessage = req.toBer() + const ber = new BerReader(ldapmessage) + t.ok(ber) + t.equal(ldapmessage.length, 102) + t.equal(ber.readSequence(), 0x30) + t.equal(ber.readInt(), 123) + t.equal(ber.readSequence(), 0x60) + t.equal(ber.readInt(), 0x03) + t.equal(ber.readString(), '') + t.equal(ber.readSequence(), 0xa3) + t.equal(ber.readString(), 'GSS-SPNEGO') + t.equal(ber.readSequence(), 0x04) + t.equal(ber.readSequence(), 0x60) // sasl type1 + t.equal(ber.readOID(), '1.3.6.1.5.5.2') + t.equal(ber.readSequence(), 0xa0) + t.equal(ber.readSequence(), 0x30) + t.equal(ber.readSequence(), 0xa0) + t.equal(ber.readSequence(), 0x30) + t.equal(ber.readOID(), '1.3.6.1.4.1.311.2.2.10') + t.equal(ber.readSequence(), 0xa2) + t.equal(ber.readString(), token) + t.end() +}) + +test('parse sasl type3', function (t) { + // The indentation here is what you'd see in Wireshark + const saslBuffer = Buffer.from( + '0201030400a382' + '022a040a4753532d' + // ....... .*..GSS- + '53504e45474f0482' + '021aa18202163082' + // SPNEGO.. ......0. + '0212a282020e0482' + '020a4e544c4d5353' + // ........ ..NTLMSS + '5000030000001800' + '18008a0000006801' + // P....... ......h. + '6801a20000000c00' + '0c00580000000c00' + + '0c00640000001a00' + '1a00700000000000' + + '00000a0200000582' + '88a20a00614a0000' + + '000fdeb54425491a' + '7704956ffc4a1f13' + + '475944004f004d00' + '410049004e006d00' + // ..D.O.M. A.I.N.m. + '7900750073006500' + '720043004f004d00' + // y.u.s.e. r.C.O.M. + '5000550054004500' + '52002d004e004100' + // P.U.T.E. R.-.N.A. + '4d00450000000000' + '0000000000000000' + // M.E..... ........ + '0000000000000000' + '0000000000000000' + + '0000000000000000' + '0000000000000000' + + '0000000000000000' + '0000000000000000' + + '0000000000000000' + '0000000000000000' + + '0000000000000000' + '0000000000000000' + + '0000000000000000' + '0000000000000000' + + '0000000000000000' + '0000000000000000' + + '0000000000000000' + '0000000000000000' + + '0000000000000000' + '0000000000000000' + + '0000000000000000' + '0000000000000000' + + '0000000000000000' + '0000000000000000' + + '0000000000000000' + '0000000000000000' + + '0000000000000000' + '0000000000000000' + + '0000000000000000' + '0000000000000000' + + '0000000000000000' + '0000000000000000' + + '0000000000000000' + '0000000000000000' + + '0000000000000000' + '0000000000000000' + + '0000000000000000' + '0000000000000000' + + '0000000000000000' + '0000000000000000' + + '0000000000000000' + '0000000000000000' + + '0000000000000000' + '0000000000000000' + + '0000000000000000' + '0000000000000000' + + '0000000000000000' + '0000000000000000' + + '00000000', 'hex') + const req = new BindRequest() + t.ok(req._parse(new BerReader(saslBuffer))) + t.equal(req.version, 3) + t.equal(req.authentication, 'sasl') + t.equal(req.ldapMechanism, 'GSS-SPNEGO') + t.equal(req.ntlmsspIdentifier, 'NTLMSSP\x00') + t.equal(req.ntlmsspMessageType, 3) // 3 means NTLMSSP_AUTH + t.equal(req.ntlmsspDomain, 'DOMAIN') + t.equal(req.ntlmsspUser, 'myuser') + t.equal(req.ntlmsspHost, 'COMPUTER-NAME') + t.equal(req.credentials.length, 522) + t.end() +}) + +test('toBer sasl type3', function (t) { + const token = 'NTLMSSP' + '\x00'.repeat(522 - 'NTLMSSP'.length) // 522 bytes for Type3 token + const req = new BindRequest({ + messageID: 123, + version: 3, + authentication: 'sasl', + credentials: token + }) + t.ok(req) + + const ldapmessage = req.toBer() + const ber = new BerReader(ldapmessage) + t.ok(ber) + t.equal(ldapmessage.length, 574) + t.equal(ber.readSequence(), 0x30) + t.equal(ber.readInt(), 123) + t.equal(ber.readSequence(), 0x60) + t.equal(ber.readInt(), 0x03) + t.equal(ber.readString(), '') + t.equal(ber.readSequence(), 0xa3) + t.equal(ber.readString(), 'GSS-SPNEGO') + t.equal(ber.readSequence(), 0x04) + t.equal(ber.readSequence(), 0xa1) // sasl type3 + t.equal(ber.readSequence(), 0x30) + t.equal(ber.readSequence(), 0xa2) + t.equal(ber.readString(), token) + t.end() +}) diff --git a/test/messages/bind_response.test.js b/test/messages/bind_response.test.js index e9a8eddb..ac4531b9 100644 --- a/test/messages/bind_response.test.js +++ b/test/messages/bind_response.test.js @@ -3,6 +3,7 @@ const { test } = require('tap') const { BerReader, BerWriter } = require('asn1') const { BindResponse } = require('../../lib') +const { LDAP_SASL_BIND_IN_PROGRESS } = require('../../lib/errors/codes') test('new no args', function (t) { t.ok(new BindResponse()) @@ -54,3 +55,33 @@ test('toBer', function (t) { t.end() }) + +test('parse sasl type2', function (t) { + // The indentation here is what you'd see in Wireshark + const saslBuffer = Buffer.from( + /* */ '0a010e' + '0400040087820106' + + 'a18201023081ffa0' + '030a0101a10c060a' + + '2b06010401823702' + '020aa281e90481e6' + + '4e544c4d53535000' + '0000000000000000' + // NTLMSSP + '0000000000000000' + '0000000000000000' + + '0000000000000000' + '0000000000000000' + + '0000000000000000' + '0000000000000000' + + '0000000000000000' + '0000000000000000' + + '0000000000000000' + '0000000000000000' + + '0000000000000000' + '0000000000000000' + + '0000000000000000' + '0000000000000000' + + '0000000000000000' + '0000000000000000' + + '0000000000000000' + '0000000000000000' + + '0000000000000000' + '0000000000000000' + + '0000000000000000' + '0000000000000000' + + '0000000000000000' + '0000000000000000' + + '0000000000000000' + '0000000000000000' + + '000000000000', 'hex') + const res = new BindResponse() + t.ok(res._parse(new BerReader(saslBuffer))) + t.equal(res.status, LDAP_SASL_BIND_IN_PROGRESS) + t.equal(res.matchedDN, '') + t.equal(res.errorMessage, '') + t.equal(res.saslChallange.length, 230) + t.end() +})