forked from dachan/dach
187 lines
6.0 KiB
JavaScript
187 lines
6.0 KiB
JavaScript
'use strict'
|
|
const crypto = require('./utils')
|
|
|
|
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',
|
|
}
|
|
}
|
|
|
|
async 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 (password === '') {
|
|
throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: client password must be a non-empty 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 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 saltBytes = Buffer.from(sv.salt, 'base64')
|
|
var saltedPassword = await crypto.deriveKey(password, saltBytes, sv.iteration)
|
|
var clientKey = await crypto.hmacSha256(saltedPassword, 'Client Key')
|
|
var storedKey = await crypto.sha256(clientKey)
|
|
var clientSignature = await crypto.hmacSha256(storedKey, authMessage)
|
|
var clientProof = xorBuffers(Buffer.from(clientKey), Buffer.from(clientSignature)).toString('base64')
|
|
var serverKey = await crypto.hmacSha256(saltedPassword, 'Server Key')
|
|
var serverSignatureBytes = await crypto.hmacSha256(serverKey, authMessage)
|
|
|
|
session.message = 'SASLResponse'
|
|
session.serverSignature = Buffer.from(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]))
|
|
}
|
|
|
|
module.exports = {
|
|
startSession,
|
|
continueSession,
|
|
finalizeSession,
|
|
}
|