commit
807e1508d7
19 changed files with 4830 additions and 0 deletions
@ -0,0 +1,41 @@ |
|||
Basic RADIUS Server for node.js for Google LDAP Service and WPA2 Enteprise WLAN Authentification. |
|||
* Only implements LDAP as Authentification Backend |
|||
* Only WPA TTLS implemented (as this is the only one that works with Google LDAP Service) |
|||
|
|||
## Known Issues / Disclaimer |
|||
|
|||
This is a first implementation draft, which is currently NOT WORKING: |
|||
|
|||
There is still one major issue left to get things going: |
|||
https://github.com/nodejs/node/issues/31802 |
|||
that's why it's currently not possible to calculate MS-MPPE-Send-Key and MS-MPPE-Recv-Key. |
|||
|
|||
* PAP / CHAP RFC not found to implement this correctly |
|||
* Project needs more structure and interfaces to extend it more easily in the future (make a full radius server out of it ;)?) |
|||
* No package queuing or any kind of respsecting the MTU size |
|||
* a lot of bugs |
|||
|
|||
CONTRIBUTIONS WELCOME! |
|||
|
|||
## Installation |
|||
|
|||
npm install |
|||
npm run build |
|||
|
|||
## Introduction |
|||
|
|||
This app provides a radius server to authenticate against google's SLDAP service. To get this running |
|||
you need: |
|||
1.) Running LDAP Service (E.g. Google Suite Enterprise or Gloud Identity Premium) |
|||
2.) Use stunnel to connect to the LDAP service and connect this app to the stunnel (I didn't get the client ldap authentication working in here yet) |
|||
3.) Install a SSL certificate (e.g. self signed via npm run create-certificate) |
|||
4.) Install und build server: npm install && npm run build |
|||
5.) Start server node dist/app.ts --secret {RADIUS secret} --baseDN dc=hokify,dc=com |
|||
|
|||
|
|||
## Usage |
|||
|
|||
You need to specify at least a radius password and the base DN for LDAP: |
|||
|
|||
node dist/app.ts --secret {RADIUS secret} --baseDN dc=hokify,dc=com |
|||
|
Binary file not shown.
@ -0,0 +1,3 @@ |
|||
https://github.com/retailnext/node-radius/issues/29 |
|||
|
|||
https://stackoverflow.com/questions/60232165/ssl-export-keying-material-in-node-js |
File diff suppressed because it is too large
@ -0,0 +1,30 @@ |
|||
{ |
|||
"name": "radius-server", |
|||
"description": "radius server for google LDAP and TTLT", |
|||
"version": "0.0.1", |
|||
"scripts": { |
|||
"start": "node dist/app.js", |
|||
"build": "tsc", |
|||
"dev": "ts-node src/app.ts", |
|||
"test-ttls-pap": "eapol_test -c ./ttls-pap.conf -s testing123", |
|||
"create-certificate": "sh ./ssl/create.sh && sh ./ssl/sign.sh" |
|||
}, |
|||
"dependencies": { |
|||
"native-duplexpair": "^1.0.0", |
|||
"ldapjs": "^1.0.2", |
|||
"node-cache": "^5.1.0", |
|||
"radius": "~1.1.4", |
|||
"ts-node": "^8.6.2", |
|||
"type-cacheable": "^4.0.0", |
|||
"yargs": "~15.1.0", |
|||
"md5": "^2.2.1" |
|||
}, |
|||
"devDependencies": { |
|||
"@types/ldapjs": "^1.0.5", |
|||
"@types/radius": "0.0.28", |
|||
"@hokify/eslint-config": "^0.2.2", |
|||
"eslint": "^6.8.0", |
|||
"prettier": "^1.19.1", |
|||
"typescript": "^3.7.5" |
|||
} |
|||
} |
@ -0,0 +1,190 @@ |
|||
import * as dgram from 'dgram'; |
|||
import * as radius from 'radius'; |
|||
// import * as dgram from "dgram";
|
|||
import * as fs from 'fs'; |
|||
import { EAPHandler } from './eap'; |
|||
import { encodeTunnelPW } from './tls/crypt'; |
|||
import { makeid } from './helpers'; |
|||
|
|||
import { LDAPAuth } from './ldap'; |
|||
|
|||
const server = dgram.createSocket('udp4'); |
|||
|
|||
// not used right now, using stunnel to connect to ldap
|
|||
const tlsOptions = { |
|||
key: fs.readFileSync('ldap.gsuite.hokify.com.40567.key'), |
|||
cert: fs.readFileSync('ldap.gsuite.hokify.com.40567.crt'), |
|||
|
|||
// This is necessary only if using the client certificate authentication.
|
|||
requestCert: true, |
|||
|
|||
// This is necessary only if the client uses the self-signed certificate.
|
|||
ca: [fs.readFileSync('ldap.gsuite.hokify.com.40567.key')] |
|||
}; |
|||
|
|||
const { argv } = require('yargs') |
|||
.usage('Simple Google LDAP <> RADIUS Server\nUsage: $0') |
|||
.example('$0 --port 1812 -s radiussecret') |
|||
.default({ |
|||
port: 1812, |
|||
s: 'testing123', |
|||
baseDN: 'dc=hokify,dc=com', |
|||
ldapServer: 'ldap://127.0.0.1:1636' |
|||
}) |
|||
.describe('baseDN', 'LDAP Base DN') |
|||
.describe('ldapServer', 'LDAP Server') |
|||
.describe('port', 'RADIUS server listener port') |
|||
.alias('s', 'secret') |
|||
.describe('secret', 'RADIUS secret') |
|||
.string(['secret', 'baseDN']) |
|||
.demand('secret'); |
|||
|
|||
console.log(`Listener Port: ${argv.port}`); |
|||
console.log(`RADIUS Secret: ${argv.secret}`); |
|||
console.log(`LDAP Base DN: ${argv.baseDN}`); |
|||
console.log(`LDAP Server: ${argv.ldapServer}`); |
|||
|
|||
// const ldap = new LDAPAuth({url: 'ldap://ldap.google.com', base: 'dc=hokify,dc=com', uid: 'uid', tlsOptions});
|
|||
|
|||
const ldap = new LDAPAuth(argv.ldapServer, argv.baseDN); |
|||
|
|||
const eapHandler = new EAPHandler(); |
|||
|
|||
server.on('message', async function(msg, rinfo) { |
|||
const packet = radius.decode({ packet: msg, secret: argv.secret }); |
|||
|
|||
if (packet.code !== 'Access-Request') { |
|||
console.log('unknown packet type: ', packet.code); |
|||
return; |
|||
} |
|||
|
|||
// console.log('packet.attributes', packet.attributes);
|
|||
|
|||
// console.log('rinfo', rinfo);
|
|||
|
|||
async function checkAuth(username: string, password: string, EAPMessageIdentifier?: number) { |
|||
console.log(`Access-Request for ${username}`); |
|||
let code: 'Access-Accept' | 'Access-Reject'; |
|||
|
|||
try { |
|||
await ldap.authenticate(username, password); |
|||
code = 'Access-Accept'; |
|||
} catch (err) { |
|||
console.error(err); |
|||
code = 'Access-Reject'; |
|||
} |
|||
|
|||
const attributes: any[] = []; |
|||
if (EAPMessageIdentifier) { |
|||
const buffer = Buffer.from([ |
|||
code === 'Access-Accept' ? 3 : 4, // 3.. success, 4... failure
|
|||
EAPMessageIdentifier, |
|||
0, // length (1/2)
|
|||
4 // length (2/2)
|
|||
]); |
|||
|
|||
attributes.push(['EAP-Message', buffer]); |
|||
} |
|||
|
|||
if (packet.attributes && packet.attributes['User-Name']) { |
|||
// reappend username to response
|
|||
attributes.push(['User-Name', packet.attributes['User-Name']]); |
|||
} |
|||
|
|||
/* |
|||
if (sess->eap_if->eapKeyDataLen > 64) { |
|||
len = 32; |
|||
} else { |
|||
len = sess->eap_if->eapKeyDataLen / 2; |
|||
} |
|||
*/ |
|||
// eapKeyData + len
|
|||
attributes.push([ |
|||
'Vendor-Specific', |
|||
311, |
|||
[ |
|||
[ |
|||
16, |
|||
encodeTunnelPW( |
|||
(packet as any).authenticator, |
|||
packet.attributes['Message-Authenticator'], |
|||
argv.secret |
|||
) |
|||
] |
|||
] |
|||
]); // MS-MPPE-Send-Key
|
|||
|
|||
// eapKeyData
|
|||
attributes.push([ |
|||
'Vendor-Specific', |
|||
311, |
|||
[ |
|||
[ |
|||
17, |
|||
encodeTunnelPW( |
|||
(packet as any).authenticator, |
|||
packet.attributes['Message-Authenticator'], |
|||
argv.secret |
|||
) |
|||
] |
|||
] |
|||
]); // MS-MPPE-Recv-Key
|
|||
|
|||
const response = radius.encode_response({ |
|||
packet, |
|||
code, |
|||
secret: argv.secret, |
|||
attributes |
|||
}); |
|||
console.log(`Sending ${code} for user ${username}`); |
|||
|
|||
server.send(response, 0, response.length, rinfo.port, rinfo.address, function(err, _bytes) { |
|||
if (err) { |
|||
console.log('Error sending response to ', rinfo); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
if (packet.attributes['EAP-Message']) { |
|||
const state = (packet.attributes.State && packet.attributes.State.toString()) || makeid(16); |
|||
// EAP MESSAGE
|
|||
eapHandler.handleEAPMessage(packet.attributes['EAP-Message'], state, { |
|||
response: (EAPMessage: Buffer) => { |
|||
const attributes: any = [['State', Buffer.from(state)]]; |
|||
let sentDataSize = 0; |
|||
do { |
|||
if (EAPMessage.length > 0) { |
|||
attributes.push(['EAP-Message', EAPMessage.slice(sentDataSize, sentDataSize + 253)]); |
|||
sentDataSize += 253; |
|||
} |
|||
} while (sentDataSize < EAPMessage.length); |
|||
|
|||
const response = radius.encode_response({ |
|||
packet, |
|||
code: 'Access-Challenge', |
|||
secret: argv.secret, |
|||
attributes |
|||
}); |
|||
|
|||
server.send(response, 0, response.length, rinfo.port, rinfo.address, function(err, _bytes) { |
|||
if (err) { |
|||
console.log('Error sending response to ', rinfo); |
|||
} |
|||
}); |
|||
}, |
|||
checkAuth |
|||
}); |
|||
} else { |
|||
const username = packet.attributes['User-Name']; |
|||
const password = packet.attributes['User-Password']; |
|||
|
|||
checkAuth(username, password); |
|||
} |
|||
}); |
|||
|
|||
server.on('listening', function() { |
|||
const address = server.address(); |
|||
console.log(`radius server listening ${address.address}:${address.port}`); |
|||
}); |
|||
|
|||
server.bind(argv.port); |
@ -0,0 +1,203 @@ |
|||
// https://tools.ietf.org/html/rfc3748#section-4.1
|
|||
|
|||
// https://tools.ietf.org/html/rfc5281 TTLS v0
|
|||
// https://tools.ietf.org/html/draft-funk-eap-ttls-v1-00 TTLS v1 (not implemented)
|
|||
import { IResponseHandlers, ResponseHandler } from './types/Handler'; |
|||
import { EAPTTLS } from './eap/eap-ttls'; |
|||
|
|||
export class EAPHandler { |
|||
maxFragmentSize = 1400; // @todo .. take framed-mtu into account from AVPs
|
|||
|
|||
eapTTLS: EAPTTLS; |
|||
|
|||
constructor() { |
|||
this.eapTTLS = new EAPTTLS(this.sendEAPResponse); |
|||
} |
|||
|
|||
/** |
|||
* |
|||
* @param data |
|||
* @param type 1 = identity, 21 = EAP-TTLS, 2 = notification, 4 = md5-challenge, 3 = NAK |
|||
*/ |
|||
private sendEAPResponse( |
|||
response: ResponseHandler, |
|||
identifier: number, |
|||
data?: Buffer, |
|||
msgType = 21, |
|||
msgFlags = 0b00000000 |
|||
) { |
|||
let i = 0; |
|||
|
|||
do { |
|||
const fragmentMaxPart = |
|||
data && (i + 1) * this.maxFragmentSize > data.length |
|||
? undefined |
|||
: (i + 1) * this.maxFragmentSize; |
|||
const sslPart = data && data.slice(i * this.maxFragmentSize, fragmentMaxPart); |
|||
|
|||
const includeLength = |
|||
data && |
|||
i === 0 && |
|||
fragmentMaxPart !== undefined; /* firsrt one and we have more, therefore include length */ |
|||
|
|||
// console.log('includeLength', includeLength, fragmentMaxPart, i)
|
|||
i += 1; |
|||
|
|||
/* |
|||
0 1 2 3 4 5 6 7 8 |
|||
+-+-+-+-+-+-+-+-+ |
|||
|L M R R R R R R| |
|||
+-+-+-+-+-+-+-+-+ |
|||
|
|||
L = Length included |
|||
M = More fragments |
|||
R = Reserved |
|||
|
|||
The L bit (length included) is set to indicate the presence of the |
|||
four-octet TLS Message Length field, and MUST be set for the first |
|||
fragment of a fragmented TLS message or set of messages. The M |
|||
bit (more fragments) is set on all but the last fragment. |
|||
Implementations of this specification MUST set the reserved bits |
|||
to zero, and MUST ignore them on reception. |
|||
*/ |
|||
|
|||
const flags = |
|||
msgFlags + |
|||
(includeLength ? 0b10000000 : 0) + // set L bit
|
|||
(fragmentMaxPart /* we have more */ ? 0b01000000 : 0); // set M bit
|
|||
|
|||
let buffer = Buffer.from([ |
|||
1, // request
|
|||
identifier + 1, |
|||
0, // length (1/2)
|
|||
0, // length (2/2)
|
|||
msgType, // 1 = identity, 21 = EAP-TTLS, 2 = notificaiton, 4 = md5-challenge, 3 = NAK
|
|||
flags // flags: 000000 (L include lenghts, M .. more to come)
|
|||
]); |
|||
|
|||
// append length
|
|||
if (includeLength && data) { |
|||
const length = Buffer.alloc(4); |
|||
length.writeInt32BE(data.byteLength, 0); |
|||
|
|||
buffer = Buffer.concat([buffer, length]); |
|||
} |
|||
|
|||
const resBuffer = sslPart ? Buffer.concat([buffer, sslPart]) : buffer; |
|||
resBuffer.writeUInt16BE(resBuffer.byteLength, 2); |
|||
|
|||
console.log('EAP RESPONSE', { |
|||
code: 1, |
|||
identifier: identifier + 1, |
|||
length: (includeLength && data && data.byteLength) || 0, |
|||
msgType, |
|||
flags, |
|||
data |
|||
}); |
|||
|
|||
// uffer.from([1,identifier, 0, 0, 21, 0]);
|
|||
// buffer.writeUInt16BE(sslResponse.length, 2); // length
|
|||
// buffer.writeInt8(21, 4); // eap-ttls
|
|||
// buffer.writeInt8(0, 5); // flags
|
|||
|
|||
/* |
|||
@todo: this is wrong, |
|||
if there are more messages, add them to a queue |
|||
and process the next one when client has ack. (message without data) |
|||
*/ |
|||
response(resBuffer); |
|||
} while (data && i * this.maxFragmentSize < data.length); |
|||
} |
|||
|
|||
handleEAPMessage(msg: Buffer, state: string, handlers: IResponseHandlers) { |
|||
// const b = Buffer.from([2,0x242,0x0,0x18,0x1,0x115,0x105,0x109,0x111,0x110,0x46,0x116,0x114,0x101,0x116,0x116,0x101,0x114]);
|
|||
// const msg = Buffer.from([2, 162, 0, 18, 1, 115, 105, 109, 111, 110, 46, 116, 114, 101, 116, 116, 101, 114])
|
|||
|
|||
/* |
|||
1 Request |
|||
2 Response |
|||
3 Success |
|||
4 Failure |
|||
*/ |
|||
|
|||
const code = msg.slice(0, 1).readUInt8(0); |
|||
const identifier = msg.slice(1, 2).readUInt8(0); // .toString('hex');
|
|||
// const length = msg.slice(2, 4).readInt16BE(0); // .toString('binary');
|
|||
const type = msg.slice(4, 5).readUInt8(0); // .slice(3,0x5).toString('hex');
|
|||
|
|||
/* |
|||
console.log("CODE", code); |
|||
console.log('ID', identifier); |
|||
console.log('length', length); |
|||
*/ |
|||
|
|||
switch (code) { |
|||
case 1: // for request
|
|||
case 2: // for response
|
|||
switch (type) { |
|||
case 1: // identifiy
|
|||
/** |
|||
* The EAP-TTLS packet format is shown below. The fields are |
|||
transmitted left to right. |
|||
|
|||
0 1 2 3 |
|||
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 |
|||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |
|||
| Code | Identifier | Length | |
|||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |
|||
| Type | Flags | Message Length |
|||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |
|||
Message Length | Data... |
|||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |
|||
*/ |
|||
|
|||
console.log('RESPONDING WITH IDENTIDY / START'); |
|||
|
|||
this.sendEAPResponse(handlers.response, identifier, undefined, 21, 0x20); |
|||
|
|||
/* |
|||
handlers.response( |
|||
Buffer.from([ |
|||
1, // request
|
|||
identifier + 1, |
|||
0, // length (1/2)
|
|||
6, // length (2/2)
|
|||
21, // EAP-TTLS
|
|||
0x20 // flags: 001000 start flag
|
|||
]) |
|||
); */ |
|||
break; |
|||
case 21: // EAP TTLS
|
|||
this.eapTTLS.handleMessage(msg, state, handlers, identifier); |
|||
return; |
|||
case 3: // nak
|
|||
this.sendEAPResponse(handlers.response, identifier, undefined, 3); |
|||
break; |
|||
case 2: // notification
|
|||
console.info('notification'); |
|||
break; |
|||
case 4: // md5-challenge
|
|||
console.info('md5-challenge'); |
|||
break; |
|||
case 254: // expanded type
|
|||
console.error('not implemented type', type); |
|||
|
|||
break; |
|||
|
|||
default: |
|||
console.error('unsupported type', type); |
|||
break; |
|||
} |
|||
break; |
|||
case 3: |
|||
console.log('Client Auth Success'); |
|||
break; |
|||
case 4: |
|||
console.log('Client Auth FAILURE'); |
|||
break; |
|||
default: |
|||
break; |
|||
// silently ignor;
|
|||
} |
|||
} |
|||
} |
@ -0,0 +1,44 @@ |
|||
import { IAuthChallenge } from '../../types/AuthChallenge'; |
|||
|
|||
export class PAPChallenge implements IAuthChallenge { |
|||
// i couldn't find any documentation about it, therefore best guess how this is processed...
|
|||
// http://www.networksorcery.com/enp/rfc/rfc1334.txt ?
|
|||
|
|||
decode(data: Buffer) { |
|||
const usrNameLength = data.slice(7, 8).readUInt8(0); |
|||
const user = data.slice(8, usrNameLength); |
|||
console.log('user', user, user.toString().trim()); |
|||
|
|||
let pwdStart = usrNameLength; // data.slice(usrNameLength);
|
|||
const passwordDelimeter = Buffer.from([0x02, 0x40, 0x00, 0x00]); |
|||
let found = false; |
|||
|
|||
let pwd: Buffer; |
|||
|
|||
do { |
|||
const possibleDelimieter = data.slice(pwdStart, pwdStart + passwordDelimeter.length); |
|||
if (possibleDelimieter.equals(passwordDelimeter)) { |
|||
found = true; |
|||
} |
|||
if (!found) { |
|||
pwdStart++; |
|||
} |
|||
} while (!found && pwdStart < data.length); |
|||
if (!found) { |
|||
throw new Error("couldn't extract password"); |
|||
} |
|||
// console.log('pwdStart+passwordDelimeter.length', pwdStart+passwordDelimeter.length);
|
|||
// console.log('length', pwdStart + data.readUInt8(pwdStart+passwordDelimeter.length));
|
|||
// first byte is a length property.. we ignore for now
|
|||
pwd = data.slice(pwdStart + passwordDelimeter.length + 1); // , pwdStart+ data.readUInt8(pwdStart+passwordDelimeter.length));
|
|||
// trim pwd
|
|||
pwd = pwd.slice(0, pwd.indexOf(0x00)); |
|||
|
|||
console.log('pwd', pwd, pwd.toString().trim().length, pwd.toString()); |
|||
|
|||
return { |
|||
username: user.toString(), |
|||
password: pwd.toString() |
|||
}; |
|||
} |
|||
} |
@ -0,0 +1,110 @@ |
|||
import * as events from 'events'; |
|||
import { openTLSSockets, startTLSServer } from '../tls/crypt'; |
|||
import { IResponseHandlers } from '../types/Handler'; |
|||
import { PAPChallenge } from './challenges/pap'; |
|||
import { IEAPType } from '../types/EAPType'; |
|||
|
|||
export class EAPTTLS implements IEAPType { |
|||
papChallenge: PAPChallenge; |
|||
|
|||
constructor(private sendEAPResponse) { |
|||
this.papChallenge = new PAPChallenge(); |
|||
} |
|||
|
|||
handleMessage(msg: Buffer, state: string, handlers, identifier: number) { |
|||
const flags = msg.slice(5, 6); // .toString('hex');
|
|||
|
|||
// if (flags)
|
|||
// @todo check if "L" flag is set in flags
|
|||
const msglength = msg.slice(6, 10).readInt32BE(0); // .toString('hex');
|
|||
const data = msg.slice(6, msg.length); // 10); //.toString('hex');
|
|||
|
|||
// check if no data package is there and we have something in the queue, if so.. empty the queue first
|
|||
if (!data) { |
|||
// @todo: queue processing
|
|||
console.warn('no data, just a confirmation!'); |
|||
return; |
|||
} |
|||
|
|||
console.log('incoming EAP TTLS', { |
|||
flags /* |
|||
0 1 2 3 4 5 6 7 |
|||
+---+---+---+---+---+---+---+---+ |
|||
| L | M | S | R | R | V | |
|||
+---+---+---+---+---+---+---+---+ |
|||
|
|||
L = Length included |
|||
M = More fragments |
|||
S = Start |
|||
R = Reserved |
|||
V = Version (000 for EAP-TTLSv0) |
|||
*/, |
|||
msglength, |
|||
data, |
|||
dataStr: data.toString() |
|||
}); |
|||
|
|||
let sslLayer = openTLSSockets.get(state) as |
|||
| { socket: events.EventEmitter; currentHandlers: IResponseHandlers } |
|||
| undefined; |
|||
if (!sslLayer) { |
|||
const newSocket = startTLSServer(); |
|||
sslLayer = { socket: newSocket, currentHandlers: handlers }; |
|||
openTLSSockets.set(state, sslLayer); |
|||
|
|||
// register event listeners
|
|||
newSocket.on('incoming', (incomingData: Buffer) => { |
|||
const type = incomingData.slice(3, 4).readUInt8(0); |
|||
// const code = data.slice(4, 5).readUInt8(0);
|
|||
|
|||
switch (type) { |
|||
case 1: // PAP / CHAP
|
|||
try { |
|||
const { username, password } = this.papChallenge.decode(incomingData); |
|||
sslLayer!.currentHandlers.checkAuth(username, password, identifier); |
|||
} catch (err) { |
|||
// pwd not found..
|
|||
console.error('pwd not found', err); |
|||
// NAK
|
|||
this.sendEAPResponse(sslLayer!.currentHandlers.response, identifier, undefined, 3); |
|||
newSocket.emit('end'); |
|||
throw new Error(`pwd not found`); |
|||
} |
|||
break; |
|||
default: |
|||
console.log('data', incomingData); |
|||
console.log('data str', incomingData.toString()); |
|||
|
|||
newSocket.emit('end'); |
|||
throw new Error(`unsupported auth type${type}`); |
|||
} |
|||
}); |
|||
|
|||
newSocket.on('response', (responseData: Buffer) => { |
|||
console.log('sending encrypted data back to client', responseData); |
|||
|
|||
// send back...
|
|||
this.sendEAPResponse(sslLayer!.currentHandlers.response, identifier, responseData); |
|||
// this.sendMessage(TYPE.PRELOGIN, data, false);
|
|||
}); |
|||
|
|||
newSocket.on('end', () => { |
|||
// cleanup socket
|
|||
console.log('ENDING SOCKET'); |
|||
openTLSSockets.del(state); |
|||
}); |
|||
} else { |
|||
console.log('using existing socket'); |
|||
} |
|||
|
|||
// update handlers
|
|||
sslLayer.currentHandlers = { |
|||
...handlers, |
|||
checkAuth: (username: string, password: string) => |
|||
handlers.checkAuth(username, password, identifier) |
|||
}; |
|||
|
|||
// emit data to tls server
|
|||
sslLayer.socket.emit('send', data); |
|||
} |
|||
} |
@ -0,0 +1,9 @@ |
|||
export function makeid(length) { |
|||
let result = ''; |
|||
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; |
|||
const charactersLength = characters.length; |
|||
for (let i = 0; i < length; i++) { |
|||
result += characters.charAt(Math.floor(Math.random() * charactersLength)); |
|||
} |
|||
return result; |
|||
} |
@ -0,0 +1,124 @@ |
|||
import * as NodeCache from 'node-cache'; |
|||
|
|||
import { createClient, Client } from 'ldapjs'; |
|||
|
|||
const usernameFields = ['posixUid', 'mail']; |
|||
|
|||
// TLS:
|
|||
// https://github.com/ldapjs/node-ldapjs/issues/307
|
|||
|
|||
export class LDAPAuth { |
|||
cache = new NodeCache(); |
|||
|
|||
ldap: Client; |
|||
|
|||
lastDNsFetch: Date; |
|||
|
|||
allValidDNsCache: { [key: string]: string }; |
|||
|
|||
constructor(private url: string, private base: string, tlsOptions?) { |
|||
this.ldap = createClient({ url, tlsOptions }).on('error', error => { |
|||
console.error('Error in ldap', error); |
|||
}); |
|||
|
|||
this.fetchDNs(); |
|||
} |
|||
|
|||
async fetchDNs() { |
|||
const dns: { [key: string]: string } = {}; |
|||
|
|||
await new Promise((resolve, reject) => { |
|||
this.ldap.search( |
|||
this.base, |
|||
{ |
|||
scope: 'sub' |
|||
}, |
|||
(err, res) => { |
|||
if (err) { |
|||
reject(err); |
|||
return; |
|||
} |
|||
|
|||
res.on('searchEntry', function(entry) { |
|||
// console.log('entry: ' + JSON.stringify(entry.object));
|
|||
usernameFields.forEach(field => { |
|||
const index = entry.object[field] as string; |
|||
dns[index] = entry.object.dn; |
|||
}); |
|||
}); |
|||
|
|||
res.on('searchReference', function(referral) { |
|||
console.log(`referral: ${referral.uris.join()}`); |
|||
}); |
|||
|
|||
res.on('error', function(ldapErr) { |
|||
console.error(`error: ${ldapErr.message}`); |
|||
reject(); |
|||
}); |
|||
|
|||
res.on('end', result => { |
|||
console.log(`status: ${result?.status}`); |
|||
|
|||
// replace with new dns
|
|||
this.allValidDNsCache = dns; |
|||
// console.log('allValidDNsCache', this.allValidDNsCache);
|
|||
resolve(); |
|||
}); |
|||
} |
|||
); |
|||
}); |
|||
this.lastDNsFetch = new Date(); |
|||
} |
|||
|
|||
async authenticate(username: string, password: string, count = 0, forceFetching = false) { |
|||
const cacheKey = `usr:${username}|pwd:${password}`; |
|||
const fromCache = this.cache.get(cacheKey); |
|||
if (fromCache) { |
|||
return fromCache; |
|||
} |
|||
|
|||
const cacheValidTime = new Date(); |
|||
cacheValidTime.setHours(cacheValidTime.getHours() - 12); |
|||
|
|||
let dnsFetched = false; |
|||
|
|||
if (!this.lastDNsFetch || this.lastDNsFetch < cacheValidTime || forceFetching) { |
|||
console.log('fetching dns'); |
|||
await this.fetchDNs(); |
|||
dnsFetched = true; |
|||
} |
|||
|
|||
if (count > 5) { |
|||
throw new Error('Failed to authenticate with LDAP!'); |
|||
} |
|||
// const dn = ;
|
|||
const dn = this.allValidDNsCache[username]; |
|||
if (!dn) { |
|||
if (!dnsFetched && !forceFetching) { |
|||
return this.authenticate(username, password, count, true); |
|||
} |
|||
throw new Error(`invalid username, not found in DN: ${username}`); |
|||
} |
|||
|
|||
await new Promise((resolve, reject) => { |
|||
this.ldap.bind(dn, password, (err, res) => { |
|||
if (err) { |
|||
if (err && (err as any).stack && (err as any).stack.includes(`${this.url} closed`)) { |
|||
count++; |
|||
// wait 1 second to give the ldap error handler time to reconnect
|
|||
setTimeout(() => resolve(this.authenticate(dn, password)), 2000); |
|||
return; |
|||
} |
|||
console.error('ldap error', err); |
|||
reject(err); |
|||
} |
|||
if (res) resolve(res); |
|||
else reject(); |
|||
}); |
|||
}); |
|||
|
|||
this.cache.set(cacheKey, true, 86400); |
|||
|
|||
return true; |
|||
} |
|||
} |
@ -0,0 +1,225 @@ |
|||
import * as NodeCache from 'node-cache'; |
|||
import * as events from 'events'; |
|||
import * as tls from 'tls'; |
|||
import { createSecureContext } from 'tls'; |
|||
import * as fs from 'fs'; |
|||
import * as crypto from 'crypto'; |
|||
import * as DuplexPair from 'native-duplexpair'; |
|||
import { makeid } from '../helpers'; |
|||
|
|||
// https://nodejs.org/api/tls.html
|
|||
const tlsOptions = { |
|||
cert: fs.readFileSync('./ssl/public-cert.pem'), |
|||
key: fs.readFileSync('./ssl/private-key.pem'), |
|||
ecdhCurve: 'auto' |
|||
}; |
|||
const secureContext = createSecureContext(tlsOptions); |
|||
export const openTLSSockets = new NodeCache({ useClones: false, stdTTL: 3600 }); // keep sockets for about one hour
|
|||
|
|||
export function startTLSServer(): events.EventEmitter { |
|||
const duplexpair = new DuplexPair(); |
|||
const emitter = new events.EventEmitter(); |
|||
|
|||
const cleartext = new tls.TLSSocket(duplexpair.socket1, { |
|||
secureContext, |
|||
isServer: true |
|||
}); |
|||
const encrypted = duplexpair.socket2; |
|||
|
|||
emitter.on('send', (data: Buffer) => { |
|||
encrypted.write(data); |
|||
// encrypted.sync();
|
|||
}); |
|||
|
|||
encrypted.on('data', (data: Buffer) => { |
|||
// console.log('encrypted data', data, data.toString());
|
|||
emitter.emit('response', data); |
|||
}); |
|||
|
|||
cleartext.on('secure', () => { |
|||
const cipher = cleartext.getCipher(); |
|||
|
|||
/* |
|||
console.log('Authorized', cleartext.authorized); |
|||
console.log('getTLSTicket', cleartext.getTLSTicket()); |
|||
console.log('getEphemeralKeyInfo', cleartext.getEphemeralKeyInfo()); |
|||
console.log('getPeerCertificate', cleartext.getPeerCertificate()); |
|||
console.log('getSharedSigalgs', cleartext.getSharedSigalgs()); |
|||
console.log('getCertificate', cleartext.getCertificate()); |
|||
console.log('getSession', cleartext.getSession()); |
|||
*/ |
|||
|
|||
if (cipher) { |
|||
console.log(`TLS negotiated (${cipher.name}, ${cipher.version})`); |
|||
} |
|||
|
|||
cleartext.on('data', (data: Buffer) => { |
|||
// console.log('cleartext data', data, data.toString());
|
|||
emitter.emit('incoming', data); |
|||
}); |
|||
|
|||
cleartext.once('close', (data: Buffer) => { |
|||
console.log('cleartext close'); |
|||
emitter.emit('end'); |
|||
}); |
|||
|
|||
cleartext.on('keylog', line => { |
|||
console.log('############ KEYLOG #############', line); |
|||
// cleartext.getTicketKeys()
|
|||
}); |
|||
|
|||
console.log('*********** new client connection established / secured ********'); |
|||
// this.emit('secure', securePair.cleartext);
|
|||
// this.encryptAllFutureTraffic();
|
|||
}); |
|||
|
|||
cleartext.on('error', (err?: Error) => { |
|||
console.log('cleartext error', err); |
|||
|
|||
encrypted.destroy(); |
|||
cleartext.destroy(err); |
|||
|
|||
emitter.emit('end'); |
|||
}); |
|||
|
|||
return emitter; |
|||
} |
|||
|
|||
function md5Hex(buffer: Buffer): Buffer { |
|||
const hasher = crypto.createHash('md5'); |
|||
hasher.update(buffer); |
|||
return hasher.digest(); // new Buffer(hasher.digest("binary"), "binary");
|
|||
} |
|||
|
|||
export function encodeTunnelPW(key: Buffer, authenticator: Buffer, secret: string): Buffer { |
|||
// see freeradius TTLS implementation how to obtain "key"......
|
|||
|
|||
// key should be:
|
|||
// https://www.openssl.org/docs/man1.0.2/man3/SSL_export_keying_material.html
|
|||
// https://github.com/nodejs/ffi/blob/master/deps/openssl/openssl/doc/man3/SSL_export_keying_material.pod
|
|||
|
|||
// but not available in NODE JS
|
|||
|
|||
console.log('KEY', key); |
|||
console.log('authenticator', authenticator); |
|||
console.log('secret', secret); |
|||
// https://tools.ietf.org/html/rfc2548
|
|||
|
|||
/** |
|||
* Salt |
|||
The Salt field is two octets in length and is used to ensure the |
|||
uniqueness of the keys used to encrypt each of the encrypted |
|||
attributes occurring in a given Access-Accept packet. The most |
|||
significant bit (leftmost) of the Salt field MUST be set (1). The |
|||
contents of each Salt field in a given Access-Accept packet MUST |
|||
be unique. |
|||
*/ |
|||
const salt = Buffer.concat([ |
|||
// eslint-disable-next-line no-bitwise
|
|||
Buffer.from((Number(makeid(1)) & 0b10000000).toString()), // ensure left most bit is set (1)
|
|||
Buffer.from(makeid(1)) |
|||
]); |
|||
|
|||
console.log('salt', salt); |
|||
// ensure left most bit is set to 1
|
|||
|
|||
/* |
|||
String |
|||
The plaintext String field consists of three logical sub-fields: |
|||
the Key-Length and Key sub-fields (both of which are required), |
|||
and the optional Padding sub-field. The Key-Length sub-field is |
|||
one octet in length and contains the length of the unencrypted Key |
|||
sub-field. The Key sub-field contains the actual encryption key. |
|||
If the combined length (in octets) of the unencrypted Key-Length |
|||
and Key sub-fields is not an even multiple of 16, then the Padding |
|||
sub-field MUST be present. If it is present, the length of the |
|||
Padding sub-field is variable, between 1 and 15 octets. The |
|||
String field MUST be encrypted as follows, prior to transmission: |
|||
|
|||
Construct a plaintext version of the String field by concate- |
|||
nating the Key-Length and Key sub-fields. If necessary, pad |
|||
the resulting string until its length (in octets) is an even |
|||
multiple of 16. It is recommended that zero octets (0x00) be |
|||
used for padding. Call this plaintext P. |
|||
*/ |
|||
|
|||
console.log('key', key.length, key); |
|||
let P = Buffer.concat([new Uint8Array([key.length]), key]); // + key + padding;
|
|||
|
|||
// fill up with 0x00 till we have % 16
|
|||
while (P.length % 16 !== 0) { |
|||
P = Buffer.concat([P, Buffer.from([0x00])]); |
|||
} |
|||
// console.log('PLAINTEXT', P.length, P);
|
|||
/* |
|||
Call the shared secret S, the pseudo-random 128-bit Request |
|||
Authenticator (from the corresponding Access-Request packet) R, |
|||
and the contents of the Salt field A. Break P into 16 octet |
|||
chunks p(1), p(2)...p(i), where i = len(P)/16. Call the |
|||
ciphertext blocks c(1), c(2)...c(i) and the final ciphertext C. |
|||
Intermediate values b(1), b(2)...c(i) are required. Encryption |
|||
is performed in the following manner ('+' indicates |
|||
concatenation): |
|||
*/ |
|||
|
|||
const p: Buffer[] = []; |
|||
for (let i = 0; i < P.length; i += 16) { |
|||
p.push(P.slice(i, i + 16)); |
|||
} |
|||
|
|||
const S = secret; |
|||
const R = authenticator; |
|||
const A = salt; |
|||
|
|||
// console.log('S', S);
|
|||
// console.log('R', R);
|
|||
// console.log('A', A);
|
|||
|
|||
// const P = Buffer.alloc(16);
|
|||
|
|||
let C; |
|||
const c: { [key: number]: Buffer } = {}; |
|||
const b: { [key: number]: Buffer } = {}; |
|||
|
|||
// console.log('S + R + A', S + R + A);
|
|||
|
|||
for (let i = 0; i < p.length; i++) { |
|||
// one octet is 8.. therefore +=2 means next 16
|
|||
if (!i) { |
|||
b[i] = md5Hex(Buffer.concat([Buffer.from(S), R, A])); |
|||
} else { |
|||
b[i] = md5Hex(Buffer.concat([Buffer.from(S), c[i - 1]])); |
|||
} |
|||
|
|||
c[i] = Buffer.alloc(16); // ''; //p[i];
|
|||
for (let n = 0; n < p[i].length; n++) { |
|||
// eslint-disable-next-line no-bitwise
|
|||
c[i][n] = p[i][n] ^ b[i][n]; |
|||
} |
|||
|
|||
// console.log('c['+i+']', c[i]);
|
|||
// console.log('b['+i+']', b[i]);
|
|||
|
|||
C = C ? Buffer.concat([C, c[i]]) : c[i]; |
|||
} |
|||
|
|||
const bufferC = Buffer.from(C); |
|||
console.log('BUFFER C', bufferC.length, bufferC); |
|||
return Buffer.concat([salt, bufferC]); |
|||
/* |
|||
Zorn Informational [Page 21] |
|||
|
|||
RFC 2548 Microsoft Vendor-specific RADIUS Attributes March 1999 |
|||
|
|||
|
|||
b(1) = MD5(S + R + A) c(1) = p(1) xor b(1) C = c(1) |
|||
b(2) = MD5(S + c(1)) c(2) = p(2) xor b(2) C = C + c(2) |
|||
. . |
|||
. . |
|||
. . |
|||
b(i) = MD5(S + c(i-1)) c(i) = p(i) xor b(i) C = C + c(i) |
|||
|
|||
The resulting encrypted String field will contain |
|||
c(1)+c(2)+...+c(i). |
|||
*/ |
|||
} |
@ -0,0 +1,3 @@ |
|||
export interface IAuthChallenge { |
|||
decode(data: Buffer): { username: string; password: string }; |
|||
} |
@ -0,0 +1,3 @@ |
|||
export interface IEAPType { |
|||
handleMessage(msg: Buffer, state: string, handlers, identifier: number) |
|||
} |
@ -0,0 +1,7 @@ |
|||
export type ResponseHandler = (msg: Buffer) => void; |
|||
export type ResponseAuthHandler = (username: string, password: string, identifier: number) => void; |
|||
|
|||
export interface IResponseHandlers { |
|||
response: ResponseHandler; |
|||
checkAuth: ResponseAuthHandler; |
|||
} |
@ -0,0 +1,2 @@ |
|||
openssl genrsa -out private-key.pem 1024 |
|||
openssl req -new -key private-key.pem -out csr.pem |
@ -0,0 +1 @@ |
|||
openssl x509 -req -in csr.pem -signkey private-key.pem -out public-cert.pem |
@ -0,0 +1,11 @@ |
|||
{ |
|||
"extends": "./tsconfig.json", |
|||
"include": [ |
|||
"src/**/*.ts", |
|||
"*.js" |
|||
], |
|||
"compilerOptions": { |
|||
"allowJs": true, |
|||
"checkJs": true |
|||
} |
|||
} |
@ -0,0 +1,27 @@ |
|||
{ |
|||
"compilerOptions": { |
|||
"outDir": "./dist", |
|||
"rootDir": "./src", |
|||
|
|||
// target settings for node 10 |
|||
"module": "commonjs", |
|||
"target": "es2018", |
|||
"lib": ["es2018"], |
|||
|
|||
// other best practice configs |
|||
"moduleResolution": "node", |
|||
"strict": true, |
|||
"noImplicitAny": false, // <-- get rid of this! |
|||
"removeComments": false, // <-- do not remove comments (needed for @deprecated notices etc) |
|||
"emitDecoratorMetadata": true, |
|||
"composite": true, |
|||
"experimentalDecorators": true, |
|||
"strictPropertyInitialization": false, |
|||
"resolveJsonModule": true, |
|||
"sourceMap": true, |
|||
"isolatedModules": false, |
|||
"declaration": true |
|||
}, |
|||
"exclude": ["node_modules", "**/__tests__"], |
|||
"include": ["./src"] |
|||
} |
Loading…
Reference in new issue