Source code

Revision control

Copy as Markdown

Other Tools

Test Info: Warnings

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
// End-to-end native NTLM against a server that actually validates the
// response, rather than one that accepts any type 3 message.
//
// The server issues a real type 2 with a fresh random challenge, then
// recomputes the NTLMv2 proof from a password it knows and compares it against
// what the client sent:
//
// NTHash = MD4(UTF16LE(password))
// NTLMv2Hash = HMAC-MD5(NTHash, UTF16LE(uppercase(user) + domain))
// NTProofStr = HMAC-MD5(NTLMv2Hash, serverChallenge + blob)
//
// where blob is everything after the first 16 bytes of NtChallengeResponse.
// That makes the test sensitive to the bytes SSPI produces, not just to the
// message type, and it lets a wrong password be distinguished from a right one.
//
// The client is driven with explicit credentials: localhost is deliberately not
// in network.automatic-ntlm-auth.trusted-uris, so CanUseDefaultCredentials() is
// false and nsHttpNTLMAuth takes the Windows branch that uses the native module
// with identityInvalid = true. nsAuthSSPI::Init passes the prompted credentials
// to AcquireCredentialsHandle, so the response derives from a password the test
// chose. With default credentials SSPI has no usable secret on a machine that
// is not domain joined and fails with SEC_E_NO_CREDENTIALS.
"use strict";
/* globals require, global, Buffer */
const { NodeHTTPServer } = ChromeUtils.importESModule(
);
const DOMAIN = "TESTDOMAIN";
const USER = "testuser";
const PASSWORD = "testpass";
// ---------------------------------------------------------------------------
// Code below runs inside the node child process. It is stringified and eval'd
// there, so it may only use require(), Buffer and global.
// ---------------------------------------------------------------------------
// Node's crypto still exposes MD5 but OpenSSL 3 moved MD4 to the legacy
// provider, and NTLM needs MD4 for the NT hash.
function nodeMd4(input) {
const rotl = (v, n) => ((v << n) | (v >>> (32 - n))) >>> 0;
const F = (x, y, z) => ((x & y) | (~x & z)) >>> 0;
const G = (x, y, z) => ((x & y) | (x & z) | (y & z)) >>> 0;
const H = (x, y, z) => (x ^ y ^ z) >>> 0;
const total = Math.ceil((input.length + 9) / 64) * 64;
const m = Buffer.alloc(total);
input.copy(m);
m[input.length] = 0x80;
const bits = input.length * 8;
m.writeUInt32LE(bits >>> 0, total - 8);
m.writeUInt32LE(Math.floor(bits / 4294967296), total - 4);
// Each step updates one of a,b,c,d in the rotating order a,d,c,b and reads
// the other three in order starting after the target.
const order = [0, 3, 2, 1];
const state = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476];
const words = new Array(16);
const round2Index = [0, 4, 8, 12, 1, 5, 9, 13, 2, 6, 10, 14, 3, 7, 11, 15];
const round3Index = [0, 8, 4, 12, 2, 10, 6, 14, 1, 9, 5, 13, 3, 11, 7, 15];
const round1Shift = [3, 7, 11, 19];
const round2Shift = [3, 5, 9, 13];
const round3Shift = [3, 9, 11, 15];
for (let offset = 0; offset < total; offset += 64) {
for (let i = 0; i < 16; i++) {
words[i] = m.readUInt32LE(offset + i * 4);
}
const saved = state.slice();
const step = (j, fn, word, shift, constant) => {
const t = order[j % 4];
const mixed = fn(
state[(t + 1) % 4],
state[(t + 2) % 4],
state[(t + 3) % 4]
);
state[t] = rotl((state[t] + mixed + word + constant) >>> 0, shift);
};
for (let j = 0; j < 16; j++) {
step(j, F, words[j], round1Shift[j % 4], 0);
}
for (let j = 0; j < 16; j++) {
step(j, G, words[round2Index[j]], round2Shift[j % 4], 0x5a827999);
}
for (let j = 0; j < 16; j++) {
step(j, H, words[round3Index[j]], round3Shift[j % 4], 0x6ed9eba1);
}
for (let i = 0; i < 4; i++) {
state[i] = (state[i] + saved[i]) >>> 0;
}
}
const out = Buffer.alloc(16);
for (let i = 0; i < 4; i++) {
out.writeUInt32LE(state[i], i * 4);
}
return out;
}
// Known answer tests, so a broken MD4 shows up as a bad digest rather than as a
// mysteriously rejected password. The third is the documented NT hash of
// "password" from MS-NLMP.
function nodeMd4SelfTest() {
return [
global.md4(Buffer.alloc(0)).toString("hex"),
global.md4(Buffer.from("abc", "latin1")).toString("hex"),
global.md4(Buffer.from("password", "utf16le")).toString("hex"),
].join(",");
}
// A type 2 message with a fresh challenge. TargetInfo has to be present and
// non-empty or the client answers with NTLMv1 instead of NTLMv2.
function nodeMakeChallenge() {
const crypto = require("crypto");
const serverChallenge = crypto.randomBytes(8);
const avPair = (id, value) => {
const encoded = Buffer.from(value, "utf16le");
const header = Buffer.alloc(4);
header.writeUInt16LE(id, 0);
header.writeUInt16LE(encoded.length, 2);
return Buffer.concat([header, encoded]);
};
const targetName = Buffer.from("DOMAIN", "utf16le");
const targetInfo = Buffer.concat([
avPair(2, "DOMAIN"), // MsvAvNbDomainName
avPair(1, "SERVER"), // MsvAvNbComputerName
avPair(4, "domain.com"), // MsvAvDnsDomainName
avPair(3, "server.domain.com"), // MsvAvDnsComputerName
Buffer.alloc(4), // MsvAvEOL
]);
// UNICODE | NTLM | TARGET_TYPE_DOMAIN | TARGET_INFO
const flags = 0x00810201;
const header = Buffer.alloc(48);
header.write("NTLMSSP\0", 0, "latin1");
header.writeUInt32LE(2, 8);
header.writeUInt16LE(targetName.length, 12);
header.writeUInt16LE(targetName.length, 14);
header.writeUInt32LE(48, 16);
header.writeUInt32LE(flags, 20);
serverChallenge.copy(header, 24);
header.writeUInt16LE(targetInfo.length, 40);
header.writeUInt16LE(targetInfo.length, 42);
header.writeUInt32LE(48 + targetName.length, 44);
return {
message: Buffer.concat([header, targetName, targetInfo]),
serverChallenge,
};
}
function nodeValidateType3(message, serverChallenge, password) {
const crypto = require("crypto");
const field = (lengthAt, offsetAt) => {
const length = message.readUInt16LE(lengthAt);
const offset = message.readUInt32LE(offsetAt);
return message.subarray(offset, offset + length);
};
const ntResponse = field(20, 24);
const domain = field(28, 32).toString("utf16le");
const user = field(36, 40).toString("utf16le");
const workstation = field(44, 48).toString("utf16le");
const result = {
user,
domain,
workstation,
ntResponseLength: ntResponse.length,
valid: false,
reason: "",
};
if (ntResponse.length <= 24) {
result.reason = "expected an NTLMv2 response";
return result;
}
const proof = ntResponse.subarray(0, 16);
const blob = ntResponse.subarray(16);
const ntHash = global.md4(Buffer.from(password, "utf16le"));
const ntlmv2Hash = crypto
.createHmac("md5", ntHash)
.update(Buffer.from(user.toUpperCase() + domain, "utf16le"))
.digest();
const expected = crypto
.createHmac("md5", ntlmv2Hash)
.update(Buffer.concat([serverChallenge, blob]))
.digest();
result.valid = expected.equals(proof);
if (!result.valid) {
result.reason = `proof ${proof.toString("hex")} != ${expected.toString(
"hex"
)}`;
}
return result;
}
// NTLM is connection based, so the challenge is remembered on the socket that
// issued it and the type 3 has to arrive on that same connection.
function ntlmHandler(req, resp) {
const finish = (status, headers, body) => {
const payload = Buffer.from(body || "", "utf8");
resp.writeHead(
status,
Object.assign({ "Content-Length": String(payload.length) }, headers)
);
resp.end(payload);
};
const header = req.headers.authorization;
if (!header || !header.startsWith("NTLM ")) {
global.ntlmRequests.push("no-credentials");
finish(401, { "WWW-Authenticate": "NTLM" }, "");
return;
}
const message = Buffer.from(header.substring("NTLM ".length), "base64");
if (message.length < 12 || message.toString("latin1", 0, 7) != "NTLMSSP") {
finish(400, {}, "not an NTLM message");
return;
}
const type = message.readUInt32LE(8);
if (type == 1) {
global.ntlmRequests.push("type1");
const challenge = global.ntlmMakeChallenge();
req.socket.ntlmServerChallenge = challenge.serverChallenge;
finish(
401,
{ "WWW-Authenticate": "NTLM " + challenge.message.toString("base64") },
""
);
return;
}
if (type == 3) {
global.ntlmRequests.push("type3");
const serverChallenge = req.socket.ntlmServerChallenge;
if (!serverChallenge) {
global.ntlmResult = {
valid: false,
reason: "type 3 on a new connection",
};
finish(403, {}, "no challenge outstanding on this connection");
return;
}
const result = global.ntlmValidateType3(
message,
serverChallenge,
global.ntlmPassword
);
global.ntlmResult = result;
if (!result.valid) {
finish(403, {}, "invalid: " + result.reason);
return;
}
finish(200, { "Content-Type": "text/plain" }, "welcome");
return;
}
finish(400, {}, "unexpected NTLM message type " + type);
}
// ---------------------------------------------------------------------------
// Test side
// ---------------------------------------------------------------------------
function AuthPrompt(password) {
this.password = password;
}
AuthPrompt.prototype = {
QueryInterface: ChromeUtils.generateQI(["nsIAuthPrompt2"]),
promptAuth(channel, level, authInfo) {
authInfo.domain = DOMAIN;
authInfo.username = USER;
authInfo.password = this.password;
return true;
},
asyncPromptAuth() {
throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
},
};
function Requestor(password) {
this.password = password;
}
Requestor.prototype = {
QueryInterface: ChromeUtils.generateQI(["nsIInterfaceRequestor"]),
getInterface(iid) {
if (iid.equals(Ci.nsIAuthPrompt2)) {
if (!this.prompt) {
this.prompt = new AuthPrompt(this.password);
}
return this.prompt;
}
throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE);
},
prompt: null,
};
function makeChan(url) {
let principal = Services.scriptSecurityManager.createContentPrincipal(
Services.io.newURI(url),
{}
);
return NetUtil.newChannel({
uri: url,
loadingPrincipal: principal,
securityFlags: Ci.nsILoadInfo.SEC_REQUIRE_SAME_ORIGIN_INHERITS_SEC_CONTEXT,
contentPolicyType: Ci.nsIContentPolicy.TYPE_OTHER,
}).QueryInterface(Ci.nsIHttpChannel);
}
let server;
async function serverState() {
return JSON.parse(
await server.execute(
"JSON.stringify({result: global.ntlmResult, requests: global.ntlmRequests})"
)
);
}
async function resetServer(password) {
await server.execute(
`global.ntlmPassword = ${JSON.stringify(password)};
global.ntlmResult = null;
global.ntlmRequests = [];`
);
Cc["@mozilla.org/network/http-auth-manager;1"]
.getService(Ci.nsIHttpAuthManager)
.clearAll();
}
async function openChannel(password) {
let url = `http://localhost:${server.port()}/ntlm`;
let chan = makeChan(url);
chan.notificationCallbacks = new Requestor(password);
return new Promise(resolve => {
chan.asyncOpen(
new ChannelListener(
(req, buffer) => resolve([req, buffer]),
null,
CL_ALLOW_UNKNOWN_CL
)
);
});
}
add_setup(async function setup() {
Services.prefs.setIntPref("network.auth.subresource-http-auth-allow", 2);
server = new NodeHTTPServer();
await server.start();
await server.execute(`global.md4 = ${nodeMd4.toString()}`);
await server.execute(`global.md4SelfTest = ${nodeMd4SelfTest.toString()}`);
await server.execute(
`global.ntlmMakeChallenge = ${nodeMakeChallenge.toString()}`
);
await server.execute(
`global.ntlmValidateType3 = ${nodeValidateType3.toString()}`
);
await server.execute("global.ntlmRequests = []; global.ntlmResult = null;");
await server.registerPathHandler("/ntlm", ntlmHandler);
registerCleanupFunction(async () => {
Services.prefs.clearUserPref("network.auth.subresource-http-auth-allow");
await server.stop();
});
// Fail here rather than as an unexplained authentication rejection later.
let vectors = await server.execute("global.md4SelfTest()");
Assert.equal(
vectors,
"31d6cfe0d16ae931b73c59d7e0c089c0," +
"a448017aaf21d8525fc10ae87aa6729d," +
"8846f7eaee8fb117ad06bdd830b7586c",
"MD4 known answer tests"
);
});
add_task(async function test_server_validates_native_response() {
await resetServer(PASSWORD);
let [request, buffer] = await openChannel(PASSWORD);
Assert.equal(request.status, Cr.NS_OK, "channel succeeded");
Assert.equal(
request.QueryInterface(Ci.nsIHttpChannel).responseStatus,
200,
"server accepted the response it recomputed"
);
Assert.equal(buffer, "welcome", "got the protected body");
let { result, requests } = await serverState();
Assert.deepEqual(
requests,
["no-credentials", "type1", "type3"],
"full handshake"
);
Assert.ok(result.valid, `proof verified: ${result.reason}`);
Assert.equal(result.user, USER, "username the client authenticated as");
Assert.equal(result.domain, DOMAIN, "domain the client authenticated as");
Assert.greater(result.ntResponseLength, 24, "NTLMv2 response");
});
// The same handshake with a password the server does not share must be
// rejected. Without this, a validator that accepted everything would look
// identical to one that works.
add_task(async function test_server_rejects_wrong_password() {
await resetServer("a-different-password");
let [request] = await openChannel(PASSWORD);
Assert.equal(
request.QueryInterface(Ci.nsIHttpChannel).responseStatus,
403,
"server rejected a proof computed from the wrong password"
);
let { result } = await serverState();
Assert.ok(!result.valid, "validation failed");
Assert.ok(
result.reason.startsWith("proof "),
`failed on the proof comparison, not earlier: ${result.reason}`
);
});