From 1db40b88cbe5b2f3895dfb07dbf9115c8229d593 Mon Sep 17 00:00:00 2001 From: James Clarke Date: Wed, 23 Aug 2023 21:54:51 +0100 Subject: [PATCH] Remove `require('crypto')` imports in browser client (#721) --- compileForDeno.ts | 18 +- packages/driver/src/adapter.crypto.deno.ts | 35 +++ packages/driver/src/adapter.crypto.node.ts | 39 +++ packages/driver/src/adapter.shared.deno.ts | 33 --- packages/driver/src/adapter.shared.node.ts | 62 ----- packages/driver/src/browserClient.ts | 7 +- packages/driver/src/browserCrypto.ts | 32 +++ packages/driver/src/fetchConn.ts | 64 ++--- packages/driver/src/httpScram.ts | 206 ++++++++------- packages/driver/src/nodeClient.ts | 11 +- packages/driver/src/rawConn.ts | 5 +- packages/driver/src/scram.ts | 289 +++++++++++---------- packages/driver/src/utils.ts | 6 + packages/driver/test/scram.test.ts | 21 +- 14 files changed, 444 insertions(+), 384 deletions(-) create mode 100644 packages/driver/src/adapter.crypto.deno.ts create mode 100644 packages/driver/src/adapter.crypto.node.ts create mode 100644 packages/driver/src/browserCrypto.ts diff --git a/compileForDeno.ts b/compileForDeno.ts index c82c6e510..dfee239ed 100644 --- a/compileForDeno.ts +++ b/compileForDeno.ts @@ -135,17 +135,13 @@ export async function run({ let resolvedImportPath = resolveImportPath(importPath, sourcePath); - if (resolvedImportPath.endsWith("/adapter.node.ts")) { - resolvedImportPath = resolvedImportPath.replace( - "/adapter.node.ts", - "/adapter.deno.ts" - ); - } - if (resolvedImportPath.endsWith("/adapter.shared.node.ts")) { - resolvedImportPath = resolvedImportPath.replace( - "/adapter.shared.node.ts", - "/adapter.shared.deno.ts" - ); + for (const name of ["adapter", "adapter.shared", "adapter.crypto"]) { + if (resolvedImportPath.endsWith(`/${name}.node.ts`)) { + resolvedImportPath = resolvedImportPath.replace( + `/${name}.node.ts`, + `/${name}.deno.ts` + ); + } } rewrittenFile.push(resolvedImportPath); diff --git a/packages/driver/src/adapter.crypto.deno.ts b/packages/driver/src/adapter.crypto.deno.ts new file mode 100644 index 000000000..f73da558e --- /dev/null +++ b/packages/driver/src/adapter.crypto.deno.ts @@ -0,0 +1,35 @@ +import { crypto } from "https://deno.land/std@0.177.0/crypto/mod.ts"; + +import type { CryptoUtils } from "./utils.ts"; + +const cryptoUtils: CryptoUtils = { + async randomBytes(size: number): Promise { + const buf = new Uint8Array(size); + return crypto.getRandomValues(buf); + }, + + async H(msg: Uint8Array): Promise { + return new Uint8Array(await crypto.subtle.digest("SHA-256", msg)); + }, + + async HMAC(key: Uint8Array, msg: Uint8Array): Promise { + return new Uint8Array( + await crypto.subtle.sign( + "HMAC", + await crypto.subtle.importKey( + "raw", + key, + { + name: "HMAC", + hash: { name: "SHA-256" }, + }, + false, + ["sign"] + ), + msg + ) + ); + }, +}; + +export default cryptoUtils; diff --git a/packages/driver/src/adapter.crypto.node.ts b/packages/driver/src/adapter.crypto.node.ts new file mode 100644 index 000000000..42e24c05f --- /dev/null +++ b/packages/driver/src/adapter.crypto.node.ts @@ -0,0 +1,39 @@ +import type { CryptoUtils } from "./utils"; + +let cryptoUtils: CryptoUtils; + +if (typeof crypto === "undefined") { + // tslint:disable-next-line:no-var-requires + const nodeCrypto = require("crypto"); + + cryptoUtils = { + randomBytes(size: number): Promise { + return new Promise((resolve, reject) => { + nodeCrypto.randomBytes(size, (err: Error | null, buf: Buffer) => { + if (err) { + reject(err); + } else { + resolve(buf); + } + }); + }); + }, + + async H(msg: Uint8Array): Promise { + const sign = nodeCrypto.createHash("sha256"); + sign.update(msg); + return sign.digest(); + }, + + async HMAC(key: Uint8Array, msg: Uint8Array): Promise { + const hm = nodeCrypto.createHmac("sha256", key); + hm.update(msg); + return hm.digest(); + }, + }; +} else { + // tslint:disable-next-line:no-var-requires + cryptoUtils = require("./browserCrypto").default; +} + +export default cryptoUtils; diff --git a/packages/driver/src/adapter.shared.deno.ts b/packages/driver/src/adapter.shared.deno.ts index ff9caf07f..2edbab714 100644 --- a/packages/driver/src/adapter.shared.deno.ts +++ b/packages/driver/src/adapter.shared.deno.ts @@ -1,36 +1,3 @@ -import { crypto } from "https://deno.land/std@0.177.0/crypto/mod.ts"; - -export async function randomBytes(size: number): Promise { - const buf = new Uint8Array(size); - return crypto.getRandomValues(buf); -} - -export async function H(msg: Uint8Array): Promise { - return new Uint8Array(await crypto.subtle.digest("SHA-256", msg)); -} - -export async function HMAC( - key: Uint8Array, - msg: Uint8Array -): Promise { - return new Uint8Array( - await crypto.subtle.sign( - "HMAC", - await crypto.subtle.importKey( - "raw", - key, - { - name: "HMAC", - hash: { name: "SHA-256" }, - }, - false, - ["sign"] - ), - msg - ) - ); -} - export function getEnv(envName: string, required = false): string | undefined { if (!required) { const state = Deno.permissions.querySync({ diff --git a/packages/driver/src/adapter.shared.node.ts b/packages/driver/src/adapter.shared.node.ts index 771daaead..0c47fe445 100644 --- a/packages/driver/src/adapter.shared.node.ts +++ b/packages/driver/src/adapter.shared.node.ts @@ -1,65 +1,3 @@ -let randomBytes: (size: number) => Promise; -let H: (msg: Uint8Array) => Promise; -let HMAC: (key: Uint8Array, msg: Uint8Array) => Promise; - -if (typeof crypto === "undefined") { - // tslint:disable-next-line:no-var-requires - const nodeCrypto = require("crypto"); - - randomBytes = (size: number): Promise => { - return new Promise((resolve, reject) => { - nodeCrypto.randomBytes(size, (err: Error | null, buf: Buffer) => { - if (err) { - reject(err); - } else { - resolve(buf); - } - }); - }); - }; - - H = async (msg: Uint8Array): Promise => { - const sign = nodeCrypto.createHash("sha256"); - sign.update(msg); - return sign.digest(); - }; - - HMAC = async (key: Uint8Array, msg: Uint8Array): Promise => { - const hm = nodeCrypto.createHmac("sha256", key); - hm.update(msg); - return hm.digest(); - }; -} else { - randomBytes = async (size: number): Promise => { - return crypto.getRandomValues(new Uint8Array(size)); - }; - - H = async (msg: Uint8Array): Promise => { - return new Uint8Array(await crypto.subtle.digest("SHA-256", msg)); - }; - - HMAC = async (key: Uint8Array, msg: Uint8Array): Promise => { - return new Uint8Array( - await crypto.subtle.sign( - "HMAC", - await crypto.subtle.importKey( - "raw", - key, - { - name: "HMAC", - hash: { name: "SHA-256" }, - }, - false, - ["sign"] - ), - msg - ) - ); - }; -} - -export { randomBytes, H, HMAC }; - export function getEnv( envName: string, required: boolean = false diff --git a/packages/driver/src/browserClient.ts b/packages/driver/src/browserClient.ts index f8501f844..255d648cc 100644 --- a/packages/driver/src/browserClient.ts +++ b/packages/driver/src/browserClient.ts @@ -1,14 +1,17 @@ import { BaseClientPool, Client, ConnectOptions } from "./baseClient"; import { getConnectArgumentsParser } from "./conUtils"; +import cryptoUtils from "./browserCrypto"; import { EdgeDBError } from "./errors"; import { FetchConnection } from "./fetchConn"; +import { getHTTPSCRAMAuth } from "./httpScram"; import { Options } from "./options"; const parseConnectArguments = getConnectArgumentsParser(null); +const httpSCRAMAuth = getHTTPSCRAMAuth(cryptoUtils); -export class FetchClientPool extends BaseClientPool { +class FetchClientPool extends BaseClientPool { isStateless = true; - _connectWithTimeout = FetchConnection.connectWithTimeout; + _connectWithTimeout = FetchConnection.createConnectWithTimeout(httpSCRAMAuth); } export function createClient(): Client { diff --git a/packages/driver/src/browserCrypto.ts b/packages/driver/src/browserCrypto.ts new file mode 100644 index 000000000..5570947c6 --- /dev/null +++ b/packages/driver/src/browserCrypto.ts @@ -0,0 +1,32 @@ +import type { CryptoUtils } from "./utils"; + +const cryptoUtils: CryptoUtils = { + async randomBytes(size: number): Promise { + return crypto.getRandomValues(new Uint8Array(size)); + }, + + async H(msg: Uint8Array): Promise { + return new Uint8Array(await crypto.subtle.digest("SHA-256", msg)); + }, + + async HMAC(key: Uint8Array, msg: Uint8Array): Promise { + return new Uint8Array( + await crypto.subtle.sign( + "HMAC", + await crypto.subtle.importKey( + "raw", + key, + { + name: "HMAC", + hash: { name: "SHA-256" }, + }, + false, + ["sign"] + ), + msg + ) + ); + }, +}; + +export default cryptoUtils; diff --git a/packages/driver/src/fetchConn.ts b/packages/driver/src/fetchConn.ts index 6c09a3f8a..827ff8a72 100644 --- a/packages/driver/src/fetchConn.ts +++ b/packages/driver/src/fetchConn.ts @@ -22,7 +22,7 @@ import { PROTO_VER, BaseRawConnection } from "./baseConn"; import Event from "./primitives/event"; import * as chars from "./primitives/chars"; import { InternalClientError, ProtocolError } from "./errors"; -import { HTTPSCRAMAuth } from "./httpScram"; +import type { HttpSCRAMAuth } from "./httpScram"; interface FetchConfig { address: Address | string; @@ -177,38 +177,40 @@ export class FetchConnection extends BaseFetchConnection { return `${baseUrl}/db/${database}`; } - static async connectWithTimeout( - addr: Address, - config: NormalizedConnectConfig, - registry: CodecsRegistry - ): Promise { - const { - connectionParams: { tlsSecurity, user, password = "", secretKey }, - } = config; - - let token = secretKey ?? _tokens.get(config); - - if (!token) { - const protocol = tlsSecurity === "insecure" ? "http" : "https"; - const baseUrl = `${protocol}://${addr[0]}:${addr[1]}`; - token = await HTTPSCRAMAuth(baseUrl, user, password); - _tokens.set(config, token); - } + static createConnectWithTimeout(httpSCRAMAuth: HttpSCRAMAuth) { + return async function connectWithTimeout( + addr: Address, + config: NormalizedConnectConfig, + registry: CodecsRegistry + ) { + const { + connectionParams: { tlsSecurity, user, password = "", secretKey }, + } = config; + + let token = secretKey ?? _tokens.get(config); + + if (!token) { + const protocol = tlsSecurity === "insecure" ? "http" : "https"; + const baseUrl = `${protocol}://${addr[0]}:${addr[1]}`; + token = await httpSCRAMAuth(baseUrl, user, password); + _tokens.set(config, token); + } - const conn = new FetchConnection( - { - address: addr, - tlsSecurity, - database: config.connectionParams.database, - user: config.connectionParams.user, - token, - }, - registry - ); + const conn = new FetchConnection( + { + address: addr, + tlsSecurity, + database: config.connectionParams.database, + user: config.connectionParams.user, + token, + }, + registry + ); - conn.connected = true; - conn.connWaiter.set(); + conn.connected = true; + conn.connWaiter.set(); - return conn; + return conn; + }; } } diff --git a/packages/driver/src/httpScram.ts b/packages/driver/src/httpScram.ts index c9c50b694..05423be30 100644 --- a/packages/driver/src/httpScram.ts +++ b/packages/driver/src/httpScram.ts @@ -5,112 +5,126 @@ import { utf8Decoder, utf8Encoder, } from "./primitives/buffer"; -import { - bufferEquals, - buildClientFinalMessage, - buildClientFirstMessage, - generateNonce, - parseServerFinalMessage, - parseServerFirstMessage, -} from "./scram"; +import { getSCRAM } from "./scram"; +import { CryptoUtils } from "./utils"; const AUTH_ENDPOINT = "/auth/token"; -export async function HTTPSCRAMAuth( +export type HttpSCRAMAuth = ( baseUrl: string, username: string, password: string -): Promise { - const authUrl = baseUrl + AUTH_ENDPOINT; - const clientNonce = await generateNonce(); - const [clientFirst, clientFirstBare] = buildClientFirstMessage( - clientNonce, - username - ); - - const serverFirstRes = await fetch(authUrl, { - headers: { - Authorization: `SCRAM-SHA-256 data=${utf8ToB64(clientFirst)}`, - }, - }); - - // The first request must have status 401 Unauthorized and provide a - // WWW-Authenticate header with a SCRAM-SHA-256 challenge. - // See: https://github.com/edgedb/edgedb/blob/09782afd3b759440abbb1b26ee19b6589be04275/edb/server/protocol/auth/scram.py#L153-L157 - const authenticateHeader = serverFirstRes.headers.get("WWW-Authenticate"); - if (serverFirstRes.status !== 401 || !authenticateHeader) { - const body = await serverFirstRes.text(); - throw new ProtocolError(`authentication failed: ${body}`); - } - - // WWW-Authenticate can contain multiple comma-separated authentication - // schemes (each with own comma-separated parameter pairs), but we only support - // one SCRAM-SHA-256 challenge, e.g., `SCRAM-SHA-256 sid=..., data=...`. - if (!authenticateHeader.startsWith("SCRAM-SHA-256")) { - throw new ProtocolError( - `unsupported authentication scheme: ${authenticateHeader}` - ); - } - - // The server may respond with a 401 Unauthorized and `WWW-Authenticate: SCRAM-SHA-256` with - // no parameters if authentication fails, e.g., due to an incorrect username. - // See: https://github.com/edgedb/edgedb/blob/09782afd3b759440abbb1b26ee19b6589be04275/edb/server/protocol/auth/scram.py#L112-L120 - const authParams = authenticateHeader.split(/ (.+)?/, 2)[1] ?? ""; - if (authParams.length === 0) { - const body = await serverFirstRes.text(); - throw new ProtocolError(`authentication failed: ${body}`); - } - - const { sid, data: serverFirst } = parseScramAttrs(authParams); - if (!sid || !serverFirst) { - throw new ProtocolError( - `authentication challenge missing attributes: expected "sid" and "data", got '${authParams}'` +) => Promise; + +export function getHTTPSCRAMAuth(cryptoUtils: CryptoUtils): HttpSCRAMAuth { + const { + bufferEquals, + generateNonce, + buildClientFirstMessage, + buildClientFinalMessage, + parseServerFirstMessage, + parseServerFinalMessage, + } = getSCRAM(cryptoUtils); + + return async function HTTPSCRAMAuth( + baseUrl: string, + username: string, + password: string + ): Promise { + const authUrl = baseUrl + AUTH_ENDPOINT; + const clientNonce = await generateNonce(); + const [clientFirst, clientFirstBare] = buildClientFirstMessage( + clientNonce, + username ); - } - - const [serverNonce, salt, iterCount] = parseServerFirstMessage(serverFirst); - const [clientFinal, expectedServerSig] = await buildClientFinalMessage( - password, - salt, - iterCount, - clientFirstBare, - serverFirst, - serverNonce - ); - const serverFinalRes = await fetch(authUrl, { - headers: { - Authorization: `SCRAM-SHA-256 sid=${sid}, data=${utf8ToB64(clientFinal)}`, - }, - }); - - // The second request is successful if the server responds with a 200 and an - // Authentication-Info header (see https://datatracker.ietf.org/doc/html/rfc7615#section-3). - // See: https://github.com/edgedb/edgedb/blob/09782afd3b759440abbb1b26ee19b6589be04275/edb/server/protocol/auth/scram.py#L252-L254 - const authInfoHeader = serverFinalRes.headers.get("Authentication-Info"); - if (!serverFinalRes.ok || !authInfoHeader) { - const body = await serverFinalRes.text(); - throw new ProtocolError(`authentication failed: ${body}`); - } - - const { data: serverFinal, sid: sidFinal } = parseScramAttrs(authInfoHeader); - if (!sidFinal || !serverFinal) { - throw new ProtocolError( - `authentication info missing attributes: expected "sid" and "data", got '${authInfoHeader}'` + const serverFirstRes = await fetch(authUrl, { + headers: { + Authorization: `SCRAM-SHA-256 data=${utf8ToB64(clientFirst)}`, + }, + }); + + // The first request must have status 401 Unauthorized and provide a + // WWW-Authenticate header with a SCRAM-SHA-256 challenge. + // See: https://github.com/edgedb/edgedb/blob/09782afd3b759440abbb1b26ee19b6589be04275/edb/server/protocol/auth/scram.py#L153-L157 + const authenticateHeader = serverFirstRes.headers.get("WWW-Authenticate"); + if (serverFirstRes.status !== 401 || !authenticateHeader) { + const body = await serverFirstRes.text(); + throw new ProtocolError(`authentication failed: ${body}`); + } + + // WWW-Authenticate can contain multiple comma-separated authentication + // schemes (each with own comma-separated parameter pairs), but we only support + // one SCRAM-SHA-256 challenge, e.g., `SCRAM-SHA-256 sid=..., data=...`. + if (!authenticateHeader.startsWith("SCRAM-SHA-256")) { + throw new ProtocolError( + `unsupported authentication scheme: ${authenticateHeader}` + ); + } + + // The server may respond with a 401 Unauthorized and `WWW-Authenticate: SCRAM-SHA-256` with + // no parameters if authentication fails, e.g., due to an incorrect username. + // See: https://github.com/edgedb/edgedb/blob/09782afd3b759440abbb1b26ee19b6589be04275/edb/server/protocol/auth/scram.py#L112-L120 + const authParams = authenticateHeader.split(/ (.+)?/, 2)[1] ?? ""; + if (authParams.length === 0) { + const body = await serverFirstRes.text(); + throw new ProtocolError(`authentication failed: ${body}`); + } + + const { sid, data: serverFirst } = parseScramAttrs(authParams); + if (!sid || !serverFirst) { + throw new ProtocolError( + `authentication challenge missing attributes: expected "sid" and "data", got '${authParams}'` + ); + } + + const [serverNonce, salt, iterCount] = parseServerFirstMessage(serverFirst); + const [clientFinal, expectedServerSig] = await buildClientFinalMessage( + password, + salt, + iterCount, + clientFirstBare, + serverFirst, + serverNonce ); - } - - if (sidFinal !== sid) { - throw new ProtocolError("SCRAM session id does not match"); - } - - const serverSig = parseServerFinalMessage(serverFinal); - if (!bufferEquals(serverSig, expectedServerSig)) { - throw new ProtocolError("server SCRAM proof does not match"); - } - const authToken = await serverFinalRes.text(); - return authToken; + const serverFinalRes = await fetch(authUrl, { + headers: { + Authorization: `SCRAM-SHA-256 sid=${sid}, data=${utf8ToB64( + clientFinal + )}`, + }, + }); + + // The second request is successful if the server responds with a 200 and an + // Authentication-Info header (see https://datatracker.ietf.org/doc/html/rfc7615#section-3). + // See: https://github.com/edgedb/edgedb/blob/09782afd3b759440abbb1b26ee19b6589be04275/edb/server/protocol/auth/scram.py#L252-L254 + const authInfoHeader = serverFinalRes.headers.get("Authentication-Info"); + if (!serverFinalRes.ok || !authInfoHeader) { + const body = await serverFinalRes.text(); + throw new ProtocolError(`authentication failed: ${body}`); + } + + const { data: serverFinal, sid: sidFinal } = + parseScramAttrs(authInfoHeader); + if (!sidFinal || !serverFinal) { + throw new ProtocolError( + `authentication info missing attributes: expected "sid" and "data", got '${authInfoHeader}'` + ); + } + + if (sidFinal !== sid) { + throw new ProtocolError("SCRAM session id does not match"); + } + + const serverSig = parseServerFinalMessage(serverFinal); + if (!bufferEquals(serverSig, expectedServerSig)) { + throw new ProtocolError("server SCRAM proof does not match"); + } + + const authToken = await serverFinalRes.text(); + return authToken; + }; } function utf8ToB64(str: string): string { diff --git a/packages/driver/src/nodeClient.ts b/packages/driver/src/nodeClient.ts index 4ae5e72a6..00b08f5c5 100644 --- a/packages/driver/src/nodeClient.ts +++ b/packages/driver/src/nodeClient.ts @@ -1,8 +1,10 @@ import { BaseClientPool, Client, ConnectOptions } from "./baseClient"; -import { FetchClientPool } from "./browserClient"; import { parseConnectArguments } from "./conUtils.server"; +import cryptoUtils from "./adapter.crypto.node"; import { Options } from "./options"; import { RawConnection } from "./rawConn"; +import { FetchConnection } from "./fetchConn"; +import { getHTTPSCRAMAuth } from "./httpScram"; class ClientPool extends BaseClientPool { isStateless = false; @@ -19,6 +21,13 @@ export function createClient(options?: string | ConnectOptions | null): Client { ); } +const httpSCRAMAuth = getHTTPSCRAMAuth(cryptoUtils); + +class FetchClientPool extends BaseClientPool { + isStateless = true; + _connectWithTimeout = FetchConnection.createConnectWithTimeout(httpSCRAMAuth); +} + export function createHttpClient( options?: string | ConnectOptions | null ): Client { diff --git a/packages/driver/src/rawConn.ts b/packages/driver/src/rawConn.ts index 1e41772dd..9048872ee 100644 --- a/packages/driver/src/rawConn.ts +++ b/packages/driver/src/rawConn.ts @@ -30,8 +30,9 @@ import { WriteMessageBuffer } from "./primitives/buffer"; import Event from "./primitives/event"; import type char from "./primitives/chars"; import * as chars from "./primitives/chars"; -import * as scram from "./scram"; +import { getSCRAM } from "./scram"; import * as errors from "./errors"; +import cryptoUtils from "./adapter.crypto.node"; enum AuthenticationStatuses { AUTH_OK = 0, @@ -40,6 +41,8 @@ enum AuthenticationStatuses { AUTH_SASL_FINAL = 12, } +const scram = getSCRAM(cryptoUtils); + const _tlsOptions = new WeakMap(); function getTlsOptions(config: ResolvedConnectConfig): tls.ConnectionOptions { if (_tlsOptions.has(config)) { diff --git a/packages/driver/src/scram.ts b/packages/driver/src/scram.ts index b7d91cc51..7de0f8303 100644 --- a/packages/driver/src/scram.ts +++ b/packages/driver/src/scram.ts @@ -16,11 +16,9 @@ * limitations under the License. */ -import { randomBytes, H, HMAC } from "./adapter.shared.node"; import { utf8Encoder, encodeB64, decodeB64 } from "./primitives/buffer"; import { ProtocolError } from "./errors"; - -export { H, HMAC }; +import type { CryptoUtils } from "./utils"; const RAW_NONCE_LENGTH = 18; @@ -34,161 +32,176 @@ export function saslprep(str: string): string { return str.normalize("NFKC"); } -export function bufferEquals(a: Uint8Array, b: Uint8Array): boolean { - if (a.length !== b.length) { - return false; - } - for (let i = 0, len = a.length; i < len; i++) { - if (a[i] !== b[i]) { +export function getSCRAM({ randomBytes, H, HMAC }: CryptoUtils) { + function bufferEquals(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) { return false; } + for (let i = 0, len = a.length; i < len; i++) { + if (a[i] !== b[i]) { + return false; + } + } + return true; } - return true; -} - -export function generateNonce( - length: number = RAW_NONCE_LENGTH -): Promise { - return randomBytes(length); -} -export function buildClientFirstMessage( - clientNonce: Uint8Array, - username: string -): [string, string] { - const bare = `n=${saslprep(username)},r=${encodeB64(clientNonce)}`; - return [`n,,${bare}`, bare]; -} - -export function parseServerFirstMessage( - msg: string -): [Uint8Array, Uint8Array, number] { - const attrs = msg.split(","); - - if (attrs.length < 3) { - throw new ProtocolError("malformed SCRAM message"); + function generateNonce( + length: number = RAW_NONCE_LENGTH + ): Promise { + return randomBytes(length); } - const nonceAttr = attrs[0]; - if (!nonceAttr || nonceAttr[0] !== "r") { - throw new ProtocolError("malformed SCRAM message"); + function buildClientFirstMessage( + clientNonce: Uint8Array, + username: string + ): [string, string] { + const bare = `n=${saslprep(username)},r=${encodeB64(clientNonce)}`; + return [`n,,${bare}`, bare]; } - const nonceB64 = nonceAttr.split("=", 2)[1]; - if (!nonceB64) { - throw new ProtocolError("malformed SCRAM message"); - } - const nonce = decodeB64(nonceB64); - const saltAttr = attrs[1]; - if (!saltAttr || saltAttr[0] !== "s") { - throw new ProtocolError("malformed SCRAM message"); - } - const saltB64 = saltAttr.split("=", 2)[1]; - if (!saltB64) { - throw new ProtocolError("malformed SCRAM message"); - } - const salt = decodeB64(saltB64); + function parseServerFirstMessage( + msg: string + ): [Uint8Array, Uint8Array, number] { + const attrs = msg.split(","); - const iterAttr = attrs[2]; - if (!iterAttr || iterAttr[0] !== "i") { - throw new ProtocolError("malformed SCRAM message"); - } - const iter = iterAttr.split("=", 2)[1]; - if (!iter || !iter.match(/^[0-9]*$/)) { - throw new ProtocolError("malformed SCRAM message"); - } - const iterCount = parseInt(iter, 10); - if (iterCount <= 0) { - throw new ProtocolError("malformed SCRAM message"); - } + if (attrs.length < 3) { + throw new ProtocolError("malformed SCRAM message"); + } - return [nonce, salt, iterCount]; -} + const nonceAttr = attrs[0]; + if (!nonceAttr || nonceAttr[0] !== "r") { + throw new ProtocolError("malformed SCRAM message"); + } + const nonceB64 = nonceAttr.split("=", 2)[1]; + if (!nonceB64) { + throw new ProtocolError("malformed SCRAM message"); + } + const nonce = decodeB64(nonceB64); -export function parseServerFinalMessage(msg: string): Uint8Array { - const attrs = msg.split(","); + const saltAttr = attrs[1]; + if (!saltAttr || saltAttr[0] !== "s") { + throw new ProtocolError("malformed SCRAM message"); + } + const saltB64 = saltAttr.split("=", 2)[1]; + if (!saltB64) { + throw new ProtocolError("malformed SCRAM message"); + } + const salt = decodeB64(saltB64); - if (attrs.length < 1) { - throw new ProtocolError("malformed SCRAM message"); - } + const iterAttr = attrs[2]; + if (!iterAttr || iterAttr[0] !== "i") { + throw new ProtocolError("malformed SCRAM message"); + } + const iter = iterAttr.split("=", 2)[1]; + if (!iter || !iter.match(/^[0-9]*$/)) { + throw new ProtocolError("malformed SCRAM message"); + } + const iterCount = parseInt(iter, 10); + if (iterCount <= 0) { + throw new ProtocolError("malformed SCRAM message"); + } - const nonceAttr = attrs[0]; - if (!nonceAttr || nonceAttr[0] !== "v") { - throw new ProtocolError("malformed SCRAM message"); + return [nonce, salt, iterCount]; } - const signatureB64 = nonceAttr.split("=", 2)[1]; - if (!signatureB64) { - throw new ProtocolError("malformed SCRAM message"); - } - return decodeB64(signatureB64); -} -export async function buildClientFinalMessage( - password: string, - salt: Uint8Array, - iterations: number, - clientFirstBare: string, - serverFirst: string, - serverNonce: Uint8Array -): Promise<[string, Uint8Array]> { - const clientFinal = `c=biws,r=${encodeB64(serverNonce)}`; - const authMessage = utf8Encoder.encode( - `${clientFirstBare},${serverFirst},${clientFinal}` - ); - const saltedPassword = await getSaltedPassword( - utf8Encoder.encode(saslprep(password)), - salt, - iterations - ); - const clientKey = await getClientKey(saltedPassword); - const storedKey = await H(clientKey); - const clientSignature = await HMAC(storedKey, authMessage); - const clientProof = XOR(clientKey, clientSignature); - - const serverKey = await getServerKey(saltedPassword); - const serverProof = await HMAC(serverKey, authMessage); - - return [`${clientFinal},p=${encodeB64(clientProof)}`, serverProof]; -} + function parseServerFinalMessage(msg: string): Uint8Array { + const attrs = msg.split(","); -export async function getSaltedPassword( - password: Uint8Array, - salt: Uint8Array, - iterations: number -): Promise { - // U1 := HMAC(str, salt + INT(1)) - - const msg = new Uint8Array(salt.length + 4); - msg.set(salt); - msg.set([0, 0, 0, 1], salt.length); + if (attrs.length < 1) { + throw new ProtocolError("malformed SCRAM message"); + } - let Hi = await HMAC(password, msg); - let Ui = Hi; + const nonceAttr = attrs[0]; + if (!nonceAttr || nonceAttr[0] !== "v") { + throw new ProtocolError("malformed SCRAM message"); + } + const signatureB64 = nonceAttr.split("=", 2)[1]; + if (!signatureB64) { + throw new ProtocolError("malformed SCRAM message"); + } + return decodeB64(signatureB64); + } + + async function buildClientFinalMessage( + password: string, + salt: Uint8Array, + iterations: number, + clientFirstBare: string, + serverFirst: string, + serverNonce: Uint8Array + ): Promise<[string, Uint8Array]> { + const clientFinal = `c=biws,r=${encodeB64(serverNonce)}`; + const authMessage = utf8Encoder.encode( + `${clientFirstBare},${serverFirst},${clientFinal}` + ); + const saltedPassword = await _getSaltedPassword( + utf8Encoder.encode(saslprep(password)), + salt, + iterations + ); + const clientKey = await _getClientKey(saltedPassword); + const storedKey = await H(clientKey); + const clientSignature = await HMAC(storedKey, authMessage); + const clientProof = _XOR(clientKey, clientSignature); + + const serverKey = await _getServerKey(saltedPassword); + const serverProof = await HMAC(serverKey, authMessage); + + return [`${clientFinal},p=${encodeB64(clientProof)}`, serverProof]; + } + + async function _getSaltedPassword( + password: Uint8Array, + salt: Uint8Array, + iterations: number + ): Promise { + // U1 := HMAC(str, salt + INT(1)) + + const msg = new Uint8Array(salt.length + 4); + msg.set(salt); + msg.set([0, 0, 0, 1], salt.length); + + let Hi = await HMAC(password, msg); + let Ui = Hi; + + for (let _ = 0; _ < iterations - 1; _++) { + Ui = await HMAC(password, Ui); + Hi = _XOR(Hi, Ui); + } - for (let _ = 0; _ < iterations - 1; _++) { - Ui = await HMAC(password, Ui); - Hi = XOR(Hi, Ui); + return Hi; } - return Hi; -} - -export function getClientKey(saltedPassword: Uint8Array): Promise { - return HMAC(saltedPassword, utf8Encoder.encode("Client Key")); -} - -export function getServerKey(saltedPassword: Uint8Array): Promise { - return HMAC(saltedPassword, utf8Encoder.encode("Server Key")); -} - -export function XOR(a: Uint8Array, b: Uint8Array): Uint8Array { - const len = a.length; - if (len !== b.length) { - throw new ProtocolError("scram.XOR: buffers are of different lengths"); + function _getClientKey(saltedPassword: Uint8Array): Promise { + return HMAC(saltedPassword, utf8Encoder.encode("Client Key")); } - const res = new Uint8Array(len); - for (let i = 0; i < len; i++) { - res[i] = a[i] ^ b[i]; + + function _getServerKey(saltedPassword: Uint8Array): Promise { + return HMAC(saltedPassword, utf8Encoder.encode("Server Key")); } - return res; + + function _XOR(a: Uint8Array, b: Uint8Array): Uint8Array { + const len = a.length; + if (len !== b.length) { + throw new ProtocolError("scram.XOR: buffers are of different lengths"); + } + const res = new Uint8Array(len); + for (let i = 0; i < len; i++) { + res[i] = a[i] ^ b[i]; + } + return res; + } + + return { + bufferEquals, + generateNonce, + buildClientFirstMessage, + parseServerFirstMessage, + parseServerFinalMessage, + buildClientFinalMessage, + _getSaltedPassword, + _getClientKey, + _getServerKey, + _XOR, + }; } diff --git a/packages/driver/src/utils.ts b/packages/driver/src/utils.ts index bd33df293..1ad50949f 100644 --- a/packages/driver/src/utils.ts +++ b/packages/driver/src/utils.ts @@ -59,3 +59,9 @@ export function versionGreaterThanOrEqual( return versionGreaterThan(left, right); } + +export interface CryptoUtils { + randomBytes: (size: number) => Promise; + H: (msg: Uint8Array) => Promise; + HMAC: (key: Uint8Array, msg: Uint8Array) => Promise; +} diff --git a/packages/driver/test/scram.test.ts b/packages/driver/test/scram.test.ts index 49b48c992..e64388960 100644 --- a/packages/driver/test/scram.test.ts +++ b/packages/driver/test/scram.test.ts @@ -16,7 +16,10 @@ * limitations under the License. */ -import * as scram from "../src/scram"; +import { getSCRAM, saslprep } from "../src/scram"; +import cryptoUtils from "../src/adapter.crypto.node"; + +const scram = getSCRAM(cryptoUtils); test("scram: RFC example", async () => { // Test SCRAM-SHA-256 against an example in RFC 7677 @@ -35,22 +38,22 @@ test("scram: RFC example", async () => { const authMessage = `${client_first},${server_first},${client_final}`; - const saltedPassword = await scram.getSaltedPassword( - Buffer.from(scram.saslprep(password), "utf-8"), + const saltedPassword = await scram._getSaltedPassword( + Buffer.from(saslprep(password), "utf-8"), Buffer.from(salt, "base64"), iterations ); - const clientKey = await scram.getClientKey(saltedPassword); - const serverKey = await scram.getServerKey(saltedPassword); - const storedKey = await scram.H(clientKey); + const clientKey = await scram._getClientKey(saltedPassword); + const serverKey = await scram._getServerKey(saltedPassword); + const storedKey = await cryptoUtils.H(clientKey); - const clientSignature = await scram.HMAC( + const clientSignature = await cryptoUtils.HMAC( storedKey, Buffer.from(authMessage, "utf8") ); - const clientProof = scram.XOR(clientKey, clientSignature); - const serverProof = await scram.HMAC( + const clientProof = scram._XOR(clientKey, clientSignature); + const serverProof = await cryptoUtils.HMAC( serverKey, Buffer.from(authMessage, "utf8") );