From 344ed3fdeef9ad659a551c5a3c814d8fdf0ab48c Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Mon, 29 Apr 2019 16:23:46 +0300 Subject: [PATCH] Add support for CPE digest authentication --- build/build.ts | 1 + lib/auth.ts | 126 ++++++++++++++++++++++------------- lib/connection-request.ts | 2 +- lib/cwmp.ts | 136 +++++++++++++++++++++++++++----------- test/auth.ts | 49 ++++++++++++++ 5 files changed, 228 insertions(+), 86 deletions(-) create mode 100644 test/auth.ts diff --git a/build/build.ts b/build/build.ts index 829d8bd..961ce8b 100644 --- a/build/build.ts +++ b/build/build.ts @@ -27,6 +27,7 @@ const externals = [ "fs", "cluster", "os", + "tls", "http", "https", "zlib", diff --git a/lib/auth.ts b/lib/auth.ts index cdd6169..7ea4735 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -19,31 +19,9 @@ import { createHash, randomBytes, pbkdf2 } from "crypto"; -export function parseAuthorizationHeader(authHeader): { method: string } { - authHeader = authHeader.trim(); - const method = authHeader.split(" ", 1)[0]; - const res = { method: method }; - - if (method === "Basic") { - // Inspired by https://github.com/jshttp/basic-auth - const USER_PASS_REGEX = /^([^:]*):(.*)$/; - const creds = USER_PASS_REGEX.exec( - Buffer.from(authHeader.slice(method.length + 1), "base64").toString() - ); - - if (!creds) throw new Error("Unable to parse auth header"); - res["username"] = creds[1]; - res["password"] = creds[2]; - } - - return res; -} - -export function parseWwwAuthenticateHeader(authHeader): {} { - authHeader = authHeader.trim(); - const method = authHeader.split(" ", 1)[0]; - const res = { method: method }; - const parts = authHeader.slice(method.length + 1).split(","); +function parseHeaderFeilds(str: string): {} { + const res = {}; + const parts = str.split(","); let part; while ((part = parts.shift()) != null) { @@ -72,33 +50,57 @@ export function parseWwwAuthenticateHeader(authHeader): {} { return res; } +export function parseAuthorizationHeader(authHeader): { method: string } { + authHeader = authHeader.trim(); + const method = authHeader.split(" ", 1)[0]; + const res = { method: method }; + + if (method === "Basic") { + // Inspired by https://github.com/jshttp/basic-auth + const USER_PASS_REGEX = /^([^:]*):(.*)$/; + const creds = USER_PASS_REGEX.exec( + Buffer.from(authHeader.slice(method.length + 1), "base64").toString() + ); + + if (!creds) throw new Error("Unable to parse auth header"); + res["username"] = creds[1]; + res["password"] = creds[2]; + } else if (method === "Digest") { + Object.assign(res, parseHeaderFeilds(authHeader.slice(method.length + 1))); + } + + return res; +} + +export function parseWwwAuthenticateHeader(authHeader): {} { + authHeader = authHeader.trim(); + const method = authHeader.split(" ", 1)[0]; + const res = { method: method }; + Object.assign(res, parseHeaderFeilds(authHeader.slice(method.length + 1))); + return res; +} + export function basic(username, password): string { return "Basic " + Buffer.from(`${username}:${password}`).toString("base64"); } export function digest( - username, - password, - uri, - httpMethod, - body, - authHeader + username: string | Buffer, + realm: string | Buffer, + password: string | Buffer, + nonce: string | Buffer, + httpMethod: string | Buffer, + uri: string | Buffer, + qop?: string | Buffer, + body?: string | Buffer, + cnonce?: string | Buffer, + nc?: string | Buffer ): string { - const cnonce = "0a4f113b"; - const nc = "00000001"; - let qop; - - if (authHeader.qop) { - if (authHeader.qop.indexOf(",") !== -1) qop = "auth"; - // Either auth or auth-int, prefer auth - else qop = authHeader.qop; - } - const ha1 = createHash("md5"); ha1 .update(username) .update(":") - .update(authHeader.realm) + .update(realm) .update(":") .update(password); // TODO support "MD5-sess" algorithm directive @@ -112,7 +114,7 @@ export function digest( if (qop === "auth-int") { const bodyHash = createHash("md5") - .update(body) + .update(body || "") .digest("hex"); ha2.update(":").update(bodyHash); } @@ -123,7 +125,7 @@ export function digest( hash .update(ha1d) .update(":") - .update(authHeader.nonce); + .update(nonce); if (qop) { hash .update(":") @@ -135,13 +137,47 @@ export function digest( } hash.update(":").update(ha2d); + return hash.digest("hex"); +} + +export function solveDigest( + username: string | Buffer, + password: string | Buffer, + uri: string | Buffer, + httpMethod: string | Buffer, + body: string | Buffer, + authHeader +): string { + const cnonce = randomBytes(8).toString("hex"); + const nc = "00000001"; + + let qop; + if (authHeader.qop) { + if (authHeader.qop.indexOf(",") !== -1) qop = "auth"; + // Either auth or auth-int, prefer auth + else qop = authHeader.qop; + } + + const hash = digest( + username, + authHeader.realm, + password, + authHeader.nonce, + httpMethod, + uri, + qop, + body, + cnonce, + nc + ); + let authString = `Digest username="${username}"`; authString += `,realm="${authHeader.realm}"`; authString += `,nonce="${authHeader.nonce}"`; authString += `,uri="${uri}"`; if (authHeader.algorithm) authString += `,algorithm=${authHeader.algorithm}`; if (qop) authString += `,qop=${qop},nc=${nc},cnonce="${cnonce}"`; - authString += `,response="${hash.digest("hex")}"`; + authString += `,response="${hash}"`; if (authHeader.opaque) authString += `,opaque="${authHeader.opaque}"`; return authString; diff --git a/lib/connection-request.ts b/lib/connection-request.ts index 3ad2d5a..e13b7a2 100644 --- a/lib/connection-request.ts +++ b/lib/connection-request.ts @@ -124,7 +124,7 @@ export async function httpConnectionRequest( opts = Object.assign( { headers: { - Authorization: auth.digest( + Authorization: auth.solveDigest( username, password, options.path, diff --git a/lib/cwmp.ts b/lib/cwmp.ts index e015a71..9fcceb1 100644 --- a/lib/cwmp.ts +++ b/lib/cwmp.ts @@ -49,14 +49,17 @@ import { import { IncomingMessage, ServerResponse } from "http"; import { Readable } from "stream"; import { promisify } from "util"; +import { TLSSocket } from "tls"; const gzipPromisified = promisify(zlib.gzip); const deflatePromisified = promisify(zlib.deflate); +const REALM = "GenieACS"; const MAX_CYCLES = 4; const MAX_CONCURRENT_REQUESTS = +config.get("MAX_CONCURRENT_REQUESTS"); const currentSessions = new WeakMap(); +const sessionsNonces = new WeakMap(); const stats = { concurrentRequests: 0, @@ -65,7 +68,10 @@ const stats = { initiatedSessions: 0 }; -async function authenticate(sessionContext: SessionContext): Promise { +async function authenticate( + sessionContext: SessionContext, + body: string | Buffer +): Promise { const authExpression: Expression = localCache.getConfigExpression( sessionContext.cacheSnapshot, "cwmp.auth" @@ -80,6 +86,21 @@ async function authenticate(sessionContext: SessionContext): Promise { ); } + if (authentication && authentication.method === "Digest") { + const sessionNonce = sessionsNonces.get( + sessionContext.httpRequest.connection + ); + + if ( + !sessionNonce || + authentication.nonce !== sessionNonce || + (authentication.qop && (!authentication.cnonce || !authentication.nc)) + ) + return false; + + authentication["body"] = body; + } + const now = Date.now(); const res = await evaluateAsync( authExpression, @@ -96,14 +117,33 @@ async function authenticate(sessionContext: SessionContext): Promise { const { fault, value } = await extensions.run(e.slice(2)); return fault ? null : value; } else if (e[1] === "AUTH") { - if (authentication && authentication["method"] === "Basic") { - return ( - authentication["username"] === e[2] && - authentication["password"] === e[3] - ); - } else { - return false; + const username = e[2]; + const password = e[3]; + if (username != null && password != null && authentication) { + if (authentication["method"] === "Basic") { + return ( + authentication["username"] === e[2] && + authentication["password"] === e[3] + ); + } + + if (authentication["method"] === "Digest") { + const expected = auth.digest( + username, + REALM, + password, + authentication["nonce"], + "POST", + authentication["uri"], + authentication["qop"], + authentication["body"], + authentication["cnonce"], + authentication["nc"] + ); + return expected === authentication["response"]; + } } + return false; } } return e; @@ -898,7 +938,7 @@ async function cacheDueTasksAndFaultsAndOperations( ); } -function reportBadState(sessionContext: SessionContext): void { +async function reportBadState(sessionContext: SessionContext): Promise { logger.accessError({ message: "Bad session state", sessionContext: sessionContext @@ -908,14 +948,45 @@ function reportBadState(sessionContext: SessionContext): void { sessionContext.httpResponse.end("Bad session state"); } +async function responseUnauthorized( + sessionContext: SessionContext +): Promise { + const authHeader = sessionContext.httpRequest.headers["authorization"]; + const resHeaders = {}; + if (authHeader) { + // Invalid credentials + logger.accessError({ + message: "Authentication failure", + sessionContext: sessionContext + }); + + resHeaders["Connection"] = "close"; + } else { + if (sessionContext.httpRequest.socket instanceof TLSSocket) { + resHeaders["WWW-Authenticate"] = `Basic realm="${REALM}"`; + } else { + const nonce = crypto.randomBytes(16).toString("hex"); + sessionsNonces.set(sessionContext.httpRequest.connection, nonce); + let d = `Digest realm="${REALM}"`; + d += ',qop="auth,auth-int"'; + d += `,nonce="${nonce}"`; + + resHeaders["WWW-Authenticate"] = d; + } + currentSessions.set(sessionContext.httpRequest.connection, sessionContext); + } + + sessionContext.httpResponse.writeHead(401, resHeaders); + sessionContext.httpResponse.end("Unauthorized"); +} + async function processRequest( sessionContext: SessionContext, rpc: SoapMessage ): Promise { if (rpc.cpeRequest) { if (rpc.cpeRequest.name === "Inform") { - if (sessionContext.state !== 0) - return void reportBadState(sessionContext); + if (sessionContext.state !== 0) return reportBadState(sessionContext); logger.accessInfo({ sessionContext: sessionContext, @@ -924,8 +995,7 @@ async function processRequest( }); return inform(sessionContext, rpc); } else if (rpc.cpeRequest.name === "TransferComplete") { - if (sessionContext.state !== 1) - return void reportBadState(sessionContext); + if (sessionContext.state !== 1) return reportBadState(sessionContext); logger.accessInfo({ sessionContext: sessionContext, @@ -957,12 +1027,12 @@ async function processRequest( throw new Error("ACS method not supported"); } } else if (rpc.cpeResponse) { - if (sessionContext.state !== 2) return void reportBadState(sessionContext); + if (sessionContext.state !== 2) return reportBadState(sessionContext); await session.rpcResponse(sessionContext, rpc.id, rpc.cpeResponse); return nextRpc(sessionContext); } else if (rpc.cpeFault) { - if (sessionContext.state !== 2) return void reportBadState(sessionContext); + if (sessionContext.state !== 2) return reportBadState(sessionContext); logger.accessWarn({ sessionContext: sessionContext, @@ -978,7 +1048,7 @@ async function processRequest( return nextRpc(sessionContext); } else { // CPE sent empty response - if (sessionContext.state !== 1) return void reportBadState(sessionContext); + if (sessionContext.state !== 1) return reportBadState(sessionContext); sessionContext.state = 2; const { faults, operations } = await session.timeoutOperations( @@ -1121,13 +1191,15 @@ async function listenerAsync( return processRequest(sessionContext, rpc); } + const newConnection = !currentSessions.has(httpRequest.connection); + const sessionContext = await getSession(httpRequest.connection, sessionId); if (sessionContext) { sessionContext.httpRequest = httpRequest; sessionContext.httpResponse = httpResponse; if ( - sessionContext.sessionId !== sessionId || + (newConnection && sessionContext.sessionId !== sessionId) || sessionContext.lastActivity + sessionContext.timeout * 1000 < Date.now() ) { logger.accessError({ @@ -1171,20 +1243,14 @@ async function listenerAsync( } if (sessionContext) { + // Authenticate in case of new connection or unauthenticated session const authenticated = - sessionContext.state === 0 ? await authenticate(sessionContext) : true; + sessionContext.state === 0 || newConnection + ? await authenticate(sessionContext, body) + : true; - if (!authenticated) { - logger.accessError({ - message: "Authentication failure", - sessionContext: sessionContext - }); + if (!authenticated) return responseUnauthorized(sessionContext); - httpResponse.writeHead(401); - httpResponse.end("Unauthorized"); - currentSessions.set(httpRequest.connection, sessionContext); - return; - } return parsedRpc(sessionContext, rpc, parseWarnings); } @@ -1262,18 +1328,8 @@ async function listenerAsync( } } - const authenticated = await authenticate(_sessionContext); - if (!authenticated) { - logger.accessError({ - message: "Authentication failure", - sessionContext: _sessionContext - }); - - httpResponse.writeHead(401); - httpResponse.end("Unauthorized"); - currentSessions.set(httpRequest.connection, _sessionContext); - return; - } + const authenticated = await authenticate(_sessionContext, body); + if (!authenticated) return responseUnauthorized(_sessionContext); parsedRpc(_sessionContext, rpc, parseWarnings); } diff --git a/test/auth.ts b/test/auth.ts new file mode 100644 index 0000000..b1e75d3 --- /dev/null +++ b/test/auth.ts @@ -0,0 +1,49 @@ +import ava from "ava"; +import { randomBytes } from "crypto"; +import * as auth from "../lib/auth"; + +ava("digest", t => { + const username = "test"; + const password = "test"; + const uri = "/"; + const method = "POST"; + const realm = "GeniceACS"; + const nonce = randomBytes(16).toString("hex"); + const body = randomBytes(128).toString(); + + const challenges = [ + `Digest realm="${realm}",nonce="${nonce}"`, + `Digest realm="${realm}",nonce="${nonce}",qop="auth"`, + `Digest realm="${realm}",nonce="${nonce}",qop="auth-int"` + ]; + + t.plan(challenges.length); + + for (const challenge of challenges) { + const wwwAuthHeader = auth.parseWwwAuthenticateHeader(challenge); + const solution = auth.solveDigest( + username, + password, + uri, + method, + body, + wwwAuthHeader + ); + const authHeader = auth.parseAuthorizationHeader(solution); + t.is( + authHeader["response"], + auth.digest( + username, + realm, + password, + nonce, + method, + uri, + authHeader["qop"], + body, + authHeader["cnonce"], + authHeader["nc"] + ) + ); + } +});