UNPKG

@j2inn/scram

Version:

TypeScript client SCRAM authentication library

244 lines (243 loc) 12.2 kB
/* * Copyright (c) 2022, J2 Innovations. All Rights Reserved */ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) { if (kind === "m") throw new TypeError("Private method is not writable"); if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter"); if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it"); return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value; }; var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) { if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter"); if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it"); return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver); }; var _ScramAuth_username, _ScramAuth_password, _ScramAuth_authUri, _ScramAuth_dkLen; import { codec, hash, misc, random } from 'sjcl'; /** * Key length in bits. */ const DK_LEN = { 'SHA-256': 32 * 8, 'SHA-384': 48 * 8, 'SHA-512': 64 * 8, }; /** * Performs the client authentication steps required by the SCRAM specification. */ export class ScramAuth { /** * Construct a new authenticator. * * @param username The username. * @param password The password. * @param uri Server URI to authenticate with. * @param fetchFunc Alternative fetch function. */ constructor(username, password, uri, fetchFunc) { _ScramAuth_username.set(this, void 0); _ScramAuth_password.set(this, void 0); _ScramAuth_authUri.set(this, void 0); _ScramAuth_dkLen.set(this, void 0); __classPrivateFieldSet(this, _ScramAuth_username, username, "f"); __classPrivateFieldSet(this, _ScramAuth_password, password, "f"); __classPrivateFieldSet(this, _ScramAuth_authUri, uri, "f"); this.fetch = fetchFunc; } /** * Performs the client authentication * * @returns Optional `authToken` in an Record<string, string> */ authenticate() { return __awaiter(this, void 0, void 0, function* () { const handshakeResponse = yield this.hello(); if ('scram' in handshakeResponse) { return this.scram(handshakeResponse); } else { throw new Error('Unsupported authentication type'); } }); } /** * Sends the 'hello' handshake message * @returns Record<string, string> or error */ hello() { return __awaiter(this, void 0, void 0, function* () { const { headers } = yield this.fetch(__classPrivateFieldGet(this, _ScramAuth_authUri, "f"), { method: 'GET', headers: { 'content-Type': 'text/plain', authorization: 'hello username=' + codec.base64url.fromBits(codec.utf8String.toBits(__classPrivateFieldGet(this, _ScramAuth_username, "f"))), }, }); const wwwAuthenticate = headers.get('www-authenticate'); if (!wwwAuthenticate) { throw new Error('Invalid authentication headers'); } const authHeaders = this.parseAuthHeaders(wwwAuthenticate); const { hash: authHash } = authHeaders; if (!authHash) { throw new Error('Invalid handshake response'); } __classPrivateFieldSet(this, _ScramAuth_dkLen, DK_LEN[authHash.toUpperCase()], "f"); if (!__classPrivateFieldGet(this, _ScramAuth_dkLen, "f")) { throw new Error(`Unsupported hashing scheme: ${authHash}.`); } if (wwwAuthenticate.toLowerCase().startsWith('scram ')) { authHeaders['scram'] = 'scram'; } return authHeaders; }); } /** * Performs the SCRAM authentication logic * * @param headers The HTTP handshake response headers from the server */ scram(authHeaders) { return __awaiter(this, void 0, void 0, function* () { const { handshakeToken } = authHeaders; if (!handshakeToken) { throw new Error('Invalid handshake response'); } const firstAuthRes = yield this.clientFirstMessage(handshakeToken); const { clientFinal, saltedPassword, authMessage } = firstAuthRes; // client second message const secondAuthResponse = yield this.clientFinalMessage(clientFinal, handshakeToken); // validate server signature if (this.validateFinalResponse(secondAuthResponse, saltedPassword, authMessage)) { return secondAuthResponse; } // Should never reach here but TS compiler too dumb to realize that // and complains about fn not returning a value throw new Error(); }); } clientFirstMessage(handshakeToken) { return __awaiter(this, void 0, void 0, function* () { const clientNonce = this.generateClientNonce(); const clientFirstBare = `n=${__classPrivateFieldGet(this, _ScramAuth_username, "f")},r=${clientNonce}`; const clientFirstMsg = `n,,${clientFirstBare}`; const data = codec.base64url.fromBits(codec.utf8String.toBits(clientFirstMsg)); // send client first message const { headers: serverRes1 } = yield this.fetch(__classPrivateFieldGet(this, _ScramAuth_authUri, "f"), { method: 'GET', headers: { Authorization: `scram handshakeToken=${handshakeToken},data=${data}`, }, }); const firstAuthResponse = serverRes1 === null || serverRes1 === void 0 ? void 0 : serverRes1.get('www-authenticate'); if (!firstAuthResponse) { throw new Error('Invalid first response'); } const authHeaders = this.parseAuthHeaders(firstAuthResponse); const { data: authData, hash: authHash } = authHeaders; if (!authData || !authHash) { throw new Error('Invalid first response auth headers'); } const serverFirstMsg = codec.utf8String.fromBits(codec.base64url.toBits(authData)); const serverFirstMap = this.parseAuthHeaders(serverFirstMsg); const { r: serverNonce, s: salt, i: iterations } = serverFirstMap; if (!serverNonce || !salt || !iterations) { throw new Error('Invalid first server response'); } if (serverNonce.length < clientNonce.length || serverNonce.substring(0, clientNonce.length) !== clientNonce) { throw new Error("Client nonce doesn't match server response"); } const serverSalt = codec.base64.toBits(salt); const serverIterations = parseInt(iterations); const dkLen = DK_LEN[authHash.toUpperCase()]; if (!dkLen || dkLen !== __classPrivateFieldGet(this, _ScramAuth_dkLen, "f")) { throw new Error(`Unsupported hashing scheme: ${authHash}.`); } const saltedPassword = misc.pbkdf2(__classPrivateFieldGet(this, _ScramAuth_password, "f"), serverSalt, serverIterations, dkLen); const clientFinalNoPf = `c=biws,r=${serverNonce}`; const authMessage = `${clientFirstBare},${serverFirstMsg},${clientFinalNoPf}`; const clientKey = new misc.hmac(saltedPassword).encrypt('Client Key'); const storedKey = new hash.sha256().update(clientKey).finalize(); const clientSignature = new misc.hmac(storedKey).encrypt(authMessage); const clientProof = this.xor(clientKey, clientSignature); const clientFinal = `${clientFinalNoPf},p=${codec.base64.fromBits(clientProof)}`; return { clientFinal, saltedPassword, authMessage }; }); } clientFinalMessage(clientFinal, handshakeToken) { var _a; return __awaiter(this, void 0, void 0, function* () { const data = codec.base64url.fromBits(codec.utf8String.toBits(clientFinal)); // send client second message const { headers: serverRes2, status } = yield this.fetch(__classPrivateFieldGet(this, _ScramAuth_authUri, "f"), { method: 'GET', headers: { Authorization: `scram handshakeToken=${handshakeToken},data=${data}`, }, }); if (status !== 200 /*OK*/) { throw Error(`Invalid status response: ${status}`); } const secondAuthResponse = serverRes2.get('authentication-info'); if (secondAuthResponse) { const result = this.parseAuthHeaders(secondAuthResponse); result['x-csrf-token'] = (_a = serverRes2.get('x-csrf-token')) !== null && _a !== void 0 ? _a : ''; return result; } else { throw new Error('Invalid second response'); } }); } validateFinalResponse(secondAuthResponse, saltedPassword, authMessage) { const { data: responseData, hash: authHash } = secondAuthResponse; if (!responseData || !authHash) { throw new Error('Invalid second server response'); } if (DK_LEN[authHash] !== __classPrivateFieldGet(this, _ScramAuth_dkLen, "f")) { throw new Error('Invalid hash algorithm'); } const data = codec.utf8String.fromBits(codec.base64url.toBits(responseData)); const dataMap = this.parseAuthHeaders(data); const serverSig = dataMap.v; const serverKey = new misc.hmac(saltedPassword).encrypt('Server Key'); const clientServerSignature = new misc.hmac(serverKey).encrypt(authMessage); if (serverSig !== codec.base64.fromBits(clientServerSignature)) { throw new Error('Authentication failure. Server signature is not valid'); } return true; } generateClientNonce() { return codec.base64url.fromBits(random.randomWords(8)); } parseAuthHeaders(header) { return header .split(/\s+/) .map((v) => v.split(',')) .flat() .map((v) => v.split(/(?!\w+)=(?=\w+)/)) .filter((v) => v.length > 1) .reduce((authData, keyValuePair) => { authData[keyValuePair[0]] = keyValuePair[1]; return authData; }, {}); } xor(key, sig) { if (key.length !== sig.length) { throw new Error('Invalid arguments'); } return key.map((k, index) => k ^ sig[index]); } } _ScramAuth_username = new WeakMap(), _ScramAuth_password = new WeakMap(), _ScramAuth_authUri = new WeakMap(), _ScramAuth_dkLen = new WeakMap();