'use strict' const crypto = require('crypto') function startSession(mechanisms) { if (mechanisms.indexOf('SCRAM-SHA-256') === -1) { throw new Error('SASL: Only mechanism SCRAM-SHA-256 is currently supported') } const clientNonce = crypto.randomBytes(18).toString('base64') return { mechanism: 'SCRAM-SHA-256', clientNonce, response: 'n,,n=*,r=' + clientNonce, message: 'SASLInitialResponse', } } function continueSession(session, password, serverData) { if (session.message !== 'SASLInitialResponse') { throw new Error('SASL: Last message was not SASLInitialResponse') } if (typeof password !== 'string') { throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: client password must be a string') } if (typeof serverData !== 'string') { throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: serverData must be a string') } const sv = parseServerFirstMessage(serverData) if (!sv.nonce.startsWith(session.clientNonce)) { throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: server nonce does not start with client nonce') } else if (sv.nonce.length === session.clientNonce.length) { throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: server nonce is too short') } var saltBytes = Buffer.from(sv.salt, 'base64') var saltedPassword = Hi(password, saltBytes, sv.iteration) var clientKey = hmacSha256(saltedPassword, 'Client Key') var storedKey = sha256(clientKey) var clientFirstMessageBare = 'n=*,r=' + session.clientNonce var serverFirstMessage = 'r=' + sv.nonce + ',s=' + sv.salt + ',i=' + sv.iteration var clientFinalMessageWithoutProof = 'c=biws,r=' + sv.nonce var authMessage = clientFirstMessageBare + ',' + serverFirstMessage + ',' + clientFinalMessageWithoutProof var clientSignature = hmacSha256(storedKey, authMessage) var clientProofBytes = xorBuffers(clientKey, clientSignature) var clientProof = clientProofBytes.toString('base64') var serverKey = hmacSha256(saltedPassword, 'Server Key') var serverSignatureBytes = hmacSha256(serverKey, authMessage) session.message = 'SASLResponse' session.serverSignature = serverSignatureBytes.toString('base64') session.response = clientFinalMessageWithoutProof + ',p=' + clientProof } function finalizeSession(session, serverData) { if (session.message !== 'SASLResponse') { throw new Error('SASL: Last message was not SASLResponse') } if (typeof serverData !== 'string') { throw new Error('SASL: SCRAM-SERVER-FINAL-MESSAGE: serverData must be a string') } const { serverSignature } = parseServerFinalMessage(serverData) if (serverSignature !== session.serverSignature) { throw new Error('SASL: SCRAM-SERVER-FINAL-MESSAGE: server signature does not match') } } /** * printable = %x21-2B / %x2D-7E * ;; Printable ASCII except ",". * ;; Note that any "printable" is also * ;; a valid "value". */ function isPrintableChars(text) { if (typeof text !== 'string') { throw new TypeError('SASL: text must be a string') } return text .split('') .map((_, i) => text.charCodeAt(i)) .every((c) => (c >= 0x21 && c <= 0x2b) || (c >= 0x2d && c <= 0x7e)) } /** * base64-char = ALPHA / DIGIT / "/" / "+" * * base64-4 = 4base64-char * * base64-3 = 3base64-char "=" * * base64-2 = 2base64-char "==" * * base64 = *base64-4 [base64-3 / base64-2] */ function isBase64(text) { return /^(?:[a-zA-Z0-9+/]{4})*(?:[a-zA-Z0-9+/]{2}==|[a-zA-Z0-9+/]{3}=)?$/.test(text) } function parseAttributePairs(text) { if (typeof text !== 'string') { throw new TypeError('SASL: attribute pairs text must be a string') } return new Map( text.split(',').map((attrValue) => { if (!/^.=/.test(attrValue)) { throw new Error('SASL: Invalid attribute pair entry') } const name = attrValue[0] const value = attrValue.substring(2) return [name, value] }) ) } function parseServerFirstMessage(data) { const attrPairs = parseAttributePairs(data) const nonce = attrPairs.get('r') if (!nonce) { throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: nonce missing') } else if (!isPrintableChars(nonce)) { throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: nonce must only contain printable characters') } const salt = attrPairs.get('s') if (!salt) { throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: salt missing') } else if (!isBase64(salt)) { throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: salt must be base64') } const iterationText = attrPairs.get('i') if (!iterationText) { throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: iteration missing') } else if (!/^[1-9][0-9]*$/.test(iterationText)) { throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: invalid iteration count') } const iteration = parseInt(iterationText, 10) return { nonce, salt, iteration, } } function parseServerFinalMessage(serverData) { const attrPairs = parseAttributePairs(serverData) const serverSignature = attrPairs.get('v') if (!serverSignature) { throw new Error('SASL: SCRAM-SERVER-FINAL-MESSAGE: server signature is missing') } else if (!isBase64(serverSignature)) { throw new Error('SASL: SCRAM-SERVER-FINAL-MESSAGE: server signature must be base64') } return { serverSignature, } } function xorBuffers(a, b) { if (!Buffer.isBuffer(a)) { throw new TypeError('first argument must be a Buffer') } if (!Buffer.isBuffer(b)) { throw new TypeError('second argument must be a Buffer') } if (a.length !== b.length) { throw new Error('Buffer lengths must match') } if (a.length === 0) { throw new Error('Buffers cannot be empty') } return Buffer.from(a.map((_, i) => a[i] ^ b[i])) } function sha256(text) { return crypto.createHash('sha256').update(text).digest() } function hmacSha256(key, msg) { return crypto.createHmac('sha256', key).update(msg).digest() } function Hi(password, saltBytes, iterations) { var ui1 = hmacSha256(password, Buffer.concat([saltBytes, Buffer.from([0, 0, 0, 1])])) var ui = ui1 for (var i = 0; i < iterations - 1; i++) { ui1 = hmacSha256(password, ui1) ui = xorBuffers(ui, ui1) } return ui } module.exports = { startSession, continueSession, finalizeSession, }