mirror of
https://github.com/brianc/node-postgres.git
synced 2026-01-25 16:03:13 +00:00
The only place we are stuck with node's original crypto API is for generating md5 hashes, which are not supported by WebCrypto.
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,
|
|
}
|