You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
212 lines
7.2 KiB
212 lines
7.2 KiB
'use strict' |
|
const crypto = require('./utils') |
|
const { signatureAlgorithmHashFromCertificate } = require('./cert-signatures') |
|
|
|
function startSession(mechanisms, stream) { |
|
const candidates = ['SCRAM-SHA-256'] |
|
if (stream) candidates.unshift('SCRAM-SHA-256-PLUS') // higher-priority, so placed first |
|
|
|
const mechanism = candidates.find((candidate) => mechanisms.includes(candidate)) |
|
|
|
if (!mechanism) { |
|
throw new Error('SASL: Only mechanism(s) ' + candidates.join(' and ') + ' are supported') |
|
} |
|
|
|
if (mechanism === 'SCRAM-SHA-256-PLUS' && typeof stream.getPeerCertificate !== 'function') { |
|
// this should never happen if we are really talking to a Postgres server |
|
throw new Error('SASL: Mechanism SCRAM-SHA-256-PLUS requires a certificate') |
|
} |
|
|
|
const clientNonce = crypto.randomBytes(18).toString('base64') |
|
const gs2Header = mechanism === 'SCRAM-SHA-256-PLUS' ? 'p=tls-server-end-point' : stream ? 'y' : 'n' |
|
|
|
return { |
|
mechanism, |
|
clientNonce, |
|
response: gs2Header + ',,n=*,r=' + clientNonce, |
|
message: 'SASLInitialResponse', |
|
} |
|
} |
|
|
|
async function continueSession(session, password, serverData, stream) { |
|
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') |
|
} |
|
|
|
const clientFirstMessageBare = 'n=*,r=' + session.clientNonce |
|
const serverFirstMessage = 'r=' + sv.nonce + ',s=' + sv.salt + ',i=' + sv.iteration |
|
|
|
// without channel binding: |
|
let channelBinding = stream ? 'eSws' : 'biws' // 'y,,' or 'n,,', base64-encoded |
|
|
|
// override if channel binding is in use: |
|
if (session.mechanism === 'SCRAM-SHA-256-PLUS') { |
|
const peerCert = stream.getPeerCertificate().raw |
|
let hashName = signatureAlgorithmHashFromCertificate(peerCert) |
|
if (hashName === 'MD5' || hashName === 'SHA-1') hashName = 'SHA-256' |
|
const certHash = await crypto.hashByName(hashName, peerCert) |
|
const bindingData = Buffer.concat([Buffer.from('p=tls-server-end-point,,'), Buffer.from(certHash)]) |
|
channelBinding = bindingData.toString('base64') |
|
} |
|
|
|
const clientFinalMessageWithoutProof = 'c=' + channelBinding + ',r=' + sv.nonce |
|
const authMessage = clientFirstMessageBare + ',' + serverFirstMessage + ',' + clientFinalMessageWithoutProof |
|
|
|
const saltBytes = Buffer.from(sv.salt, 'base64') |
|
const saltedPassword = await crypto.deriveKey(password, saltBytes, sv.iteration) |
|
const clientKey = await crypto.hmacSha256(saltedPassword, 'Client Key') |
|
const storedKey = await crypto.sha256(clientKey) |
|
const clientSignature = await crypto.hmacSha256(storedKey, authMessage) |
|
const clientProof = xorBuffers(Buffer.from(clientKey), Buffer.from(clientSignature)).toString('base64') |
|
const serverKey = await crypto.hmacSha256(saltedPassword, 'Server Key') |
|
const 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, |
|
}
|
|
|