(16);
+
+ const F = (x: number, y: number, z: number) => (x & y) | (~x & z);
+ const G = (x: number, y: number, z: number) => (x & y) | (x & z) | (y & z);
+ const H = (x: number, y: number, z: number) => x ^ y ^ z;
+ const FF = (aa: number, bb: number, cc: number, dd: number, k: number, s: number) =>
+ rotl((aa + F(bb, cc, dd) + X[k]) >>> 0, s);
+ const GG = (aa: number, bb: number, cc: number, dd: number, k: number, s: number) =>
+ rotl((aa + G(bb, cc, dd) + X[k] + 0x5a827999) >>> 0, s);
+ const HH = (aa: number, bb: number, cc: number, dd: number, k: number, s: number) =>
+ rotl((aa + H(bb, cc, dd) + X[k] + 0x6ed9eba1) >>> 0, s);
+
+ for (let i = 0; i < total; i += 64) {
+ for (let j = 0; j < 16; j++) X[j] = buf.readUInt32LE(i + j * 4);
+ const aa = a, bb = b, cc = c, dd = d;
+
+ // Round 1
+ for (let k = 0; k < 16; k += 4) {
+ a = FF(a, b, c, d, k, 3);
+ d = FF(d, a, b, c, k + 1, 7);
+ c = FF(c, d, a, b, k + 2, 11);
+ b = FF(b, c, d, a, k + 3, 19);
+ }
+ // Round 2
+ for (let k = 0; k < 4; k++) {
+ a = GG(a, b, c, d, k, 3);
+ d = GG(d, a, b, c, k + 4, 5);
+ c = GG(c, d, a, b, k + 8, 9);
+ b = GG(b, c, d, a, k + 12, 13);
+ }
+ // Round 3
+ const order = [0, 8, 4, 12, 2, 10, 6, 14, 1, 9, 5, 13, 3, 11, 7, 15];
+ for (let k = 0; k < 16; k += 4) {
+ a = HH(a, b, c, d, order[k], 3);
+ d = HH(d, a, b, c, order[k + 1], 9);
+ c = HH(c, d, a, b, order[k + 2], 11);
+ b = HH(b, c, d, a, order[k + 3], 15);
+ }
+
+ a = (a + aa) >>> 0;
+ b = (b + bb) >>> 0;
+ c = (c + cc) >>> 0;
+ d = (d + dd) >>> 0;
+ }
+
+ const out = Buffer.alloc(16);
+ out.writeUInt32LE(a, 0);
+ out.writeUInt32LE(b, 4);
+ out.writeUInt32LE(c, 8);
+ out.writeUInt32LE(d, 12);
+ return out;
+}
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+const utf16le = (s: string) => Buffer.from(s, 'utf16le');
+const hmacMd5 = (key: Buffer, data: Buffer) => crypto.createHmac('md5', key).update(data).digest();
+
+/** NTOWFv2 = HMAC_MD5(MD4(UNICODE(password)), UNICODE(UPPER(user) + domain)). */
+export function ntowfv2(user: string, domain: string, password: string): Buffer {
+ const ntHash = md4(utf16le(password));
+ return hmacMd5(ntHash, utf16le(user.toUpperCase() + domain));
+}
+
+/** FILETIME: 100-ns ticks since 1601-01-01, little-endian. */
+function filetime(unixMs: number): Buffer {
+ const ticks = (BigInt(unixMs) + 11644473600000n) * 10000n;
+ const buf = Buffer.alloc(8);
+ buf.writeBigUInt64LE(ticks);
+ return buf;
+}
+
+// ─── Type 1: NEGOTIATE ──────────────────────────────────────────────────────
+
+/** Build the Type 1 (negotiate) token, base64-encoded (no "NTLM " prefix). */
+export function createType1Message(): string {
+ const msg = Buffer.alloc(32);
+ SIGNATURE.copy(msg, 0);
+ msg.writeUInt32LE(1, 8); // MessageType
+ msg.writeUInt32LE(TYPE1_FLAGS, 12); // NegotiateFlags
+ // Domain (8) and Workstation (8) fields left zeroed; offset points past header.
+ msg.writeUInt32LE(32, 16); // domain offset
+ msg.writeUInt32LE(32, 24); // workstation offset
+ return msg.toString('base64');
+}
+
+// ─── Type 2: CHALLENGE (parse) ──────────────────────────────────────────────
+
+export interface Type2Challenge {
+ serverChallenge: Buffer // 8 bytes
+ targetInfo: Buffer // AV_PAIR block (may be empty)
+ flags: number
+}
+
+/** Parse a Type 2 (challenge) token (base64 string or raw bytes). */
+export function decodeType2Message(token: string | Buffer): Type2Challenge {
+ const buf = Buffer.isBuffer(token) ? token : Buffer.from(token, 'base64');
+ if (buf.length < 32 || !buf.subarray(0, 8).equals(SIGNATURE)) {
+ throw new Error('Invalid NTLM Type 2 message');
+ }
+ const flags = buf.readUInt32LE(20);
+ const serverChallenge = Buffer.from(buf.subarray(24, 32));
+
+ let targetInfo = Buffer.alloc(0);
+ if (buf.length >= 48) {
+ const tiLen = buf.readUInt16LE(40);
+ const tiOff = buf.readUInt32LE(44);
+ if (tiLen > 0 && tiOff + tiLen <= buf.length) {
+ targetInfo = Buffer.from(buf.subarray(tiOff, tiOff + tiLen));
+ }
+ }
+ return { serverChallenge, targetInfo, flags };
+}
+
+// ─── NTLMv2 response computation ──────────────────────────────────────────────
+
+export interface NtlmV2Response {
+ ntResponse: Buffer // NTProofStr (16) + blob
+ lmResponse: Buffer // 24 bytes
+ ntProof: Buffer // 16 bytes (exposed for testing)
+}
+
+export function computeNtlmV2Response(opts: {
+ user: string
+ domain: string
+ password: string
+ serverChallenge: Buffer
+ targetInfo: Buffer
+ clientChallenge: Buffer // 8 bytes
+ timestamp: Buffer // 8-byte FILETIME
+}): NtlmV2Response {
+ const responseKey = ntowfv2(opts.user, opts.domain, opts.password);
+
+ // "temp" blob, [MS-NLMP] §3.3.2 / §2.2.2.7
+ const blob = Buffer.concat([
+ Buffer.from([0x01, 0x01, 0x00, 0x00]), // RespType + HiRespType + reserved
+ Buffer.from([0x00, 0x00, 0x00, 0x00]),
+ opts.timestamp,
+ opts.clientChallenge,
+ Buffer.from([0x00, 0x00, 0x00, 0x00]),
+ opts.targetInfo,
+ Buffer.from([0x00, 0x00, 0x00, 0x00]),
+ ]);
+
+ const ntProof = hmacMd5(responseKey, Buffer.concat([opts.serverChallenge, blob]));
+ const ntResponse = Buffer.concat([ntProof, blob]);
+
+ const lmProof = hmacMd5(responseKey, Buffer.concat([opts.serverChallenge, opts.clientChallenge]));
+ const lmResponse = Buffer.concat([lmProof, opts.clientChallenge]);
+
+ return { ntResponse, lmResponse, ntProof };
+}
+
+// ─── Type 3: AUTHENTICATE ──────────────────────────────────────────────────
+
+export interface Type3Options {
+ user: string
+ password: string
+ domain?: string
+ workstation?: string
+ challenge: Type2Challenge
+ /** Test seams — supplied randomly/by clock in production. */
+ clientChallenge?: Buffer
+ timestamp?: number // unix ms
+}
+
+/** Build the Type 3 (authenticate) token, base64-encoded (no "NTLM " prefix). */
+export function createType3Message(opts: Type3Options): string {
+ const domain = opts.domain ?? '';
+ const workstation = opts.workstation ?? '';
+ const clientChallenge = opts.clientChallenge ?? crypto.randomBytes(8);
+ const timestamp = filetime(opts.timestamp ?? Date.now());
+
+ const { ntResponse, lmResponse } = computeNtlmV2Response({
+ user: opts.user,
+ domain,
+ password: opts.password,
+ serverChallenge: opts.challenge.serverChallenge,
+ targetInfo: opts.challenge.targetInfo,
+ clientChallenge,
+ timestamp,
+ });
+
+ const domainBuf = utf16le(domain);
+ const userBuf = utf16le(opts.user);
+ const wsBuf = utf16le(workstation);
+
+ // Header: signature(8) + type(4) + 6×field(8) + flags(4) = 64 bytes.
+ const HEADER = 64;
+ const payload = Buffer.concat([lmResponse, ntResponse, domainBuf, userBuf, wsBuf]);
+ const msg = Buffer.alloc(HEADER + payload.length);
+
+ SIGNATURE.copy(msg, 0);
+ msg.writeUInt32LE(3, 8); // MessageType
+
+ let off = HEADER;
+ const writeField = (pos: number, buf: Buffer) => {
+ msg.writeUInt16LE(buf.length, pos); // Len
+ msg.writeUInt16LE(buf.length, pos + 2); // MaxLen
+ msg.writeUInt32LE(buf.length ? off : HEADER, pos + 4); // BufferOffset
+ buf.copy(msg, off);
+ off += buf.length;
+ };
+
+ writeField(12, lmResponse); // LmChallengeResponse
+ writeField(20, ntResponse); // NtChallengeResponse
+ writeField(28, domainBuf); // DomainName
+ writeField(36, userBuf); // UserName
+ writeField(44, wsBuf); // Workstation
+ // EncryptedRandomSessionKey (52): empty
+ msg.writeUInt16LE(0, 52);
+ msg.writeUInt16LE(0, 54);
+ msg.writeUInt32LE(HEADER, 56);
+
+ msg.writeUInt32LE(TYPE3_FLAGS, 60); // NegotiateFlags
+
+ return msg.toString('base64');
+}
diff --git a/src/renderer/src/components/RequestBuilder/AuthTab.tsx b/src/renderer/src/components/RequestBuilder/AuthTab.tsx
index 4a25dcd..8c9bcff 100644
--- a/src/renderer/src/components/RequestBuilder/AuthTab.tsx
+++ b/src/renderer/src/components/RequestBuilder/AuthTab.tsx
@@ -202,8 +202,8 @@ export function AuthTab({ request, onChange }: { request: ApiRequest; onChange:
/>
-
- NTLM support is pending. Add httpntlm to dependencies to enable it.
+
+ Uses NTLMv2 over a single keep-alive connection. Not supported through a proxy.
)}
diff --git a/src/tests/mock-server.test.ts b/src/tests/mock-server.test.ts
index 8468b31..0698c86 100644
--- a/src/tests/mock-server.test.ts
+++ b/src/tests/mock-server.test.ts
@@ -16,10 +16,11 @@ async function hit(
path: string,
method = 'GET',
body?: string,
+ contentType = 'application/json',
): Promise<{ status: number; text: string }> {
const resp = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
- headers: body ? { 'Content-Type': 'application/json' } : undefined,
+ headers: body ? { 'Content-Type': contentType } : undefined,
body,
});
return { status: resp.status, text: await resp.text() };
@@ -224,6 +225,80 @@ describe('mock server: body interpolation', () => {
const parsed = JSON.parse((await hit(port, '/multi/foo/bar')).text);
expect(parsed).toEqual({ a: 'foo', b: 'bar' });
});
+
+ it('reuses a value from a JSON request body', async () => {
+ const port = nextPort();
+ const mock = makeMock(port, [makeRoute({
+ method: 'POST', path: '/echo',
+ body: '{"email":"{{request.body.email}}"}',
+ })]);
+ cleanup.push(mock.id);
+ await startMock(mock);
+ const parsed = JSON.parse(
+ (await hit(port, '/echo', 'POST', '{"email":"a@b.com"}')).text,
+ );
+ expect(parsed.email).toBe('a@b.com');
+ });
+});
+
+// ─── XML request bodies ─────────────────────────────────────────────────────
+
+describe('mock server: XML request bodies', () => {
+ it('reuses a leaf value from an XML body (text/xml)', async () => {
+ const port = nextPort();
+ const mock = makeMock(port, [makeRoute({
+ method: 'POST', path: '/order',
+ headers: { 'Content-Type': 'application/xml' },
+ body: '{{request.body.order.orderId}}',
+ })]);
+ cleanup.push(mock.id);
+ await startMock(mock);
+ const xml = '42Roy';
+ const res = await hit(port, '/order', 'POST', xml, 'text/xml');
+ expect(res.text).toBe('42');
+ });
+
+ it('parses XML detected by a leading "<" even without an xml content-type', async () => {
+ const port = nextPort();
+ const mock = makeMock(port, [makeRoute({
+ method: 'POST', path: '/x',
+ body: '{{request.body.root.name}}',
+ })]);
+ cleanup.push(mock.id);
+ await startMock(mock);
+ // content-type defaults to application/json, but the body is clearly XML
+ const res = await hit(port, '/x', 'POST', 'Ada');
+ expect(res.text).toBe('Ada');
+ });
+
+ it('exposes namespaced SOAP elements via bracket access', async () => {
+ const port = nextPort();
+ const mock = makeMock(port, [makeRoute({
+ method: 'POST', path: '/soap',
+ headers: { 'Content-Type': 'text/xml' },
+ body: "{{request.body['soap:Envelope']['soap:Body'].GetPrice.item}}",
+ })]);
+ cleanup.push(mock.id);
+ await startMock(mock);
+ const envelope =
+ '- widget
';
+ const res = await hit(port, '/soap', 'POST', envelope, 'text/xml');
+ expect(res.text).toBe('widget');
+ });
+
+ it('maps repeated XML tags to an array', async () => {
+ const port = nextPort();
+ const mock = makeMock(port, [makeRoute({
+ method: 'POST', path: '/list',
+ headers: { 'Content-Type': 'application/xml' },
+ body: '{{request.body.cart.item[1]}}',
+ })]);
+ cleanup.push(mock.id);
+ await startMock(mock);
+ const xml = '- a
- b
';
+ const res = await hit(port, '/list', 'POST', xml, 'application/xml');
+ expect(res.text).toBe('b');
+ });
});
// ─── Pre-response scripts ─────────────────────────────────────────────────────
diff --git a/src/tests/ntlm-transport.test.ts b/src/tests/ntlm-transport.test.ts
new file mode 100644
index 0000000..2894476
--- /dev/null
+++ b/src/tests/ntlm-transport.test.ts
@@ -0,0 +1,174 @@
+// Copyright (c) 2024-2026 Testsmith.io
+// SPDX-License-Identifier: MIT
+
+import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
+import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'http';
+import type { Socket } from 'net';
+import crypto from 'crypto';
+
+vi.mock('../main/ipc/secret-handler', () => ({ getSecret: vi.fn().mockResolvedValue(null) }));
+
+import { performNtlmRequest } from '../main/auth-builder';
+import { ntowfv2 } from '../main/ntlm';
+
+const SIGNATURE = Buffer.from('NTLMSSP\0', 'latin1');
+const USER = 'alice';
+const DOMAIN = 'CORP';
+const PASSWORD = 'S3cret!';
+
+// Minimal Type 2 (challenge) builder for the fake server.
+function buildType2(serverChallenge: Buffer, targetInfo: Buffer): string {
+ const buf = Buffer.alloc(48 + targetInfo.length);
+ SIGNATURE.copy(buf, 0);
+ buf.writeUInt32LE(2, 8); // MessageType
+ buf.writeUInt32LE(0x00088205, 20); // flags (unicode + ntlm + target info)
+ serverChallenge.copy(buf, 24);
+ buf.writeUInt16LE(targetInfo.length, 40);
+ buf.writeUInt16LE(targetInfo.length, 42);
+ buf.writeUInt32LE(48, 44);
+ targetInfo.copy(buf, 48);
+ return buf.toString('base64');
+}
+
+const hmacMd5 = (key: Buffer, data: Buffer) => crypto.createHmac('md5', key).update(data).digest();
+
+// Server-side NTLMv2 verification: recompute NTProofStr from the client's blob.
+function verifyType3(type3: Buffer, serverChallenge: Buffer): boolean {
+ if (!type3.subarray(0, 8).equals(SIGNATURE) || type3.readUInt32LE(8) !== 3) return false;
+ const ntLen = type3.readUInt16LE(20);
+ const ntOff = type3.readUInt32LE(24);
+ const ntResponse = type3.subarray(ntOff, ntOff + ntLen);
+ const proof = ntResponse.subarray(0, 16);
+ const blob = ntResponse.subarray(16);
+ const expected = hmacMd5(ntowfv2(USER, DOMAIN, PASSWORD), Buffer.concat([serverChallenge, blob]));
+ return proof.equals(expected);
+}
+
+function authType(headerValue: string): 'type1' | 'type3' | null {
+ const m = /^NTLM\s+(.+)$/i.exec(headerValue ?? '');
+ if (!m) return null;
+ const buf = Buffer.from(m[1], 'base64');
+ if (!buf.subarray(0, 8).equals(SIGNATURE)) return null;
+ return buf.readUInt32LE(8) === 1 ? 'type1' : buf.readUInt32LE(8) === 3 ? 'type3' : null;
+}
+
+describe('performNtlmRequest — end-to-end against a real NTLM server', () => {
+ let server: Server;
+ let port: number;
+ // Challenge is bound to the socket — this is what makes the test also verify
+ // that message 3 arrives on the SAME connection as message 1.
+ const challengeBySocket = new WeakMap();
+ const targetInfo = Buffer.from('02000800430041000000', 'hex'); // tiny AV_PAIR block
+ let sawSameSocket = false;
+
+ beforeAll(async () => {
+ server = createServer((req: IncomingMessage, res: ServerResponse) => {
+ const auth = req.headers['authorization'] ?? '';
+ const kind = authType(auth);
+
+ const finish = () => {
+ if (kind === 'type1') {
+ const challenge = crypto.randomBytes(8);
+ challengeBySocket.set(req.socket, challenge);
+ res.writeHead(401, {
+ 'WWW-Authenticate': `NTLM ${buildType2(challenge, targetInfo)}`,
+ 'Content-Type': 'text/plain',
+ Connection: 'keep-alive',
+ });
+ res.end('challenge');
+ return;
+ }
+ if (kind === 'type3') {
+ const challenge = challengeBySocket.get(req.socket);
+ if (challenge) sawSameSocket = true;
+ const token = /^NTLM\s+(.+)$/i.exec(auth)![1];
+ const ok = !!challenge && verifyType3(Buffer.from(token, 'base64'), challenge);
+ if (ok) {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ authenticated: true, method: req.method, scheme: 'ntlm' }));
+ } else {
+ res.writeHead(401, { 'Content-Type': 'text/plain' });
+ res.end('bad credentials');
+ }
+ return;
+ }
+ // No NTLM header at all — demand it.
+ res.writeHead(401, { 'WWW-Authenticate': 'NTLM', 'Content-Type': 'text/plain' });
+ res.end('need ntlm');
+ };
+
+ // drain request body, then respond
+ req.on('data', () => {});
+ req.on('end', finish);
+ });
+
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
+ port = (server.address() as { port: number }).port;
+ });
+
+ afterAll(async () => {
+ await new Promise(resolve => server.close(() => resolve()));
+ });
+
+ it('completes the handshake and authenticates (GET)', async () => {
+ const resp = await performNtlmRequest({
+ url: `http://127.0.0.1:${port}/secure`,
+ method: 'GET',
+ auth: { type: 'ntlm', username: USER, password: PASSWORD, ntlmDomain: DOMAIN, ntlmWorkstation: 'PC1' },
+ vars: {},
+ baseHeaders: { accept: 'application/json' },
+ });
+
+ expect(resp.status).toBe(200);
+ const body = JSON.parse(await resp.text());
+ expect(body.authenticated).toBe(true);
+ expect(body.method).toBe('GET');
+ expect(sawSameSocket).toBe(true); // message 3 reused message 1's connection
+ });
+
+ it('sends the body on the authenticate leg (POST)', async () => {
+ const resp = await performNtlmRequest({
+ url: `http://127.0.0.1:${port}/secure`,
+ method: 'POST',
+ auth: { type: 'ntlm', username: USER, password: PASSWORD, ntlmDomain: DOMAIN },
+ vars: {},
+ baseHeaders: { 'content-type': 'application/json' },
+ body: JSON.stringify({ hello: 'world' }),
+ });
+ expect(resp.status).toBe(200);
+ expect(JSON.parse(await resp.text()).method).toBe('POST');
+ });
+
+ it('returns 401 for a wrong password', async () => {
+ const resp = await performNtlmRequest({
+ url: `http://127.0.0.1:${port}/secure`,
+ method: 'GET',
+ auth: { type: 'ntlm', username: USER, password: 'wrong-password', ntlmDomain: DOMAIN },
+ vars: {},
+ baseHeaders: {},
+ });
+ expect(resp.status).toBe(401);
+ });
+
+ it('resolves credentials from interpolation vars', async () => {
+ const resp = await performNtlmRequest({
+ url: `http://127.0.0.1:${port}/secure`,
+ method: 'GET',
+ auth: { type: 'ntlm', username: '{{u}}', password: '{{p}}', ntlmDomain: '{{d}}' },
+ vars: { u: USER, p: PASSWORD, d: DOMAIN },
+ baseHeaders: {},
+ });
+ expect(resp.status).toBe(200);
+ });
+
+ it('rejects NTLM-over-proxy with a clear error', async () => {
+ await expect(performNtlmRequest({
+ url: `http://127.0.0.1:${port}/secure`,
+ method: 'GET',
+ auth: { type: 'ntlm', username: USER, password: PASSWORD },
+ vars: {},
+ baseHeaders: {},
+ proxy: { url: 'http://localhost:8888' },
+ })).rejects.toThrow(/proxy/i);
+ });
+});
diff --git a/src/tests/ntlm.test.ts b/src/tests/ntlm.test.ts
new file mode 100644
index 0000000..2176740
--- /dev/null
+++ b/src/tests/ntlm.test.ts
@@ -0,0 +1,108 @@
+// Copyright (c) 2024-2026 Testsmith.io
+// SPDX-License-Identifier: MIT
+
+import { describe, it, expect } from 'vitest';
+import {
+ md4,
+ ntowfv2,
+ computeNtlmV2Response,
+ createType1Message,
+ decodeType2Message,
+ createType3Message,
+} from '../main/ntlm';
+
+const hex = (b: Buffer) => b.toString('hex');
+
+describe('md4', () => {
+ // RFC 1320 test vectors
+ it('hashes the empty string', () => {
+ expect(hex(md4(Buffer.from('')))).toBe('31d6cfe0d16ae931b73c59d7e0c089c0');
+ });
+ it('hashes "abc"', () => {
+ expect(hex(md4(Buffer.from('abc')))).toBe('a448017aaf21d8525fc10ae87aa6729d');
+ });
+ it('hashes the long alphanumeric vector', () => {
+ const msg = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+ expect(hex(md4(Buffer.from(msg)))).toBe('043f8582f241db351ce627e153e7f0e4');
+ });
+});
+
+// [MS-NLMP] §4.2.4 — the canonical NTLMv2 worked example.
+describe('NTLMv2 — [MS-NLMP] §4.2.4 reference vectors', () => {
+ const user = 'User';
+ const domain = 'Domain';
+ const password = 'Password';
+ const serverChallenge = Buffer.from('0123456789abcdef', 'hex');
+ const clientChallenge = Buffer.from('aaaaaaaaaaaaaaaa', 'hex');
+ const timestamp = Buffer.alloc(8); // all zeros, per the example
+ // §4.2.4.1.3 target info (AV_PAIRs: NetBIOS domain "Domain", server "Server")
+ const targetInfo = Buffer.from(
+ '02000c0044006f006d00610069006e000100' +
+ '0c0053006500720076006500720000000000',
+ 'hex',
+ );
+
+ it('NTOWFv2 matches the spec', () => {
+ expect(hex(ntowfv2(user, domain, password))).toBe('0c868a403bfd7a93a3001ef22ef02e3f');
+ });
+
+ it('NTProofStr matches the spec', () => {
+ const { ntProof } = computeNtlmV2Response({
+ user, domain, password, serverChallenge, targetInfo, clientChallenge, timestamp,
+ });
+ expect(hex(ntProof)).toBe('68cd0ab851e51c96aabc927bebef6a1c');
+ });
+
+ it('NtChallengeResponse = NTProofStr followed by the blob', () => {
+ const { ntResponse, ntProof } = computeNtlmV2Response({
+ user, domain, password, serverChallenge, targetInfo, clientChallenge, timestamp,
+ });
+ expect(ntResponse.subarray(0, 16)).toEqual(ntProof);
+ // blob begins with 0x01010000 + 4 reserved zero bytes
+ expect(hex(ntResponse.subarray(16, 24))).toBe('0101000000000000');
+ });
+});
+
+describe('NTLM message framing', () => {
+ it('Type 1 is a well-formed NTLMSSP negotiate message', () => {
+ const buf = Buffer.from(createType1Message(), 'base64');
+ expect(buf.subarray(0, 8).toString('latin1')).toBe('NTLMSSP\0');
+ expect(buf.readUInt32LE(8)).toBe(1);
+ });
+
+ it('round-trips a Type 2 challenge into a parseable Type 3', () => {
+ // Hand-build a minimal Type 2 challenge with a server challenge + target info.
+ const targetInfo = Buffer.from('02000c0044006f006d00610069006e000000', 'hex');
+ const t2 = Buffer.alloc(48 + targetInfo.length);
+ Buffer.from('NTLMSSP\0', 'latin1').copy(t2, 0);
+ t2.writeUInt32LE(2, 8);
+ Buffer.from('1122334455667788', 'hex').copy(t2, 24); // server challenge
+ t2.writeUInt16LE(targetInfo.length, 40);
+ t2.writeUInt16LE(targetInfo.length, 42);
+ t2.writeUInt32LE(48, 44);
+ targetInfo.copy(t2, 48);
+
+ const challenge = decodeType2Message(t2.toString('base64'));
+ expect(hex(challenge.serverChallenge)).toBe('1122334455667788');
+ expect(challenge.targetInfo.equals(targetInfo)).toBe(true);
+
+ const t3 = Buffer.from(createType3Message({
+ user: 'alice', password: 'secret', domain: 'CORP', workstation: 'PC1',
+ challenge,
+ clientChallenge: Buffer.from('aaaaaaaaaaaaaaaa', 'hex'),
+ timestamp: 0,
+ }), 'base64');
+
+ expect(t3.subarray(0, 8).toString('latin1')).toBe('NTLMSSP\0');
+ expect(t3.readUInt32LE(8)).toBe(3);
+ // NtChallengeResponse field present and pointing inside the message
+ const ntLen = t3.readUInt16LE(20);
+ const ntOff = t3.readUInt32LE(24);
+ expect(ntLen).toBeGreaterThan(16);
+ expect(ntOff + ntLen).toBeLessThanOrEqual(t3.length);
+ // UserName decodes back as UTF-16LE
+ const userLen = t3.readUInt16LE(36);
+ const userOff = t3.readUInt32LE(40);
+ expect(t3.subarray(userOff, userOff + userLen).toString('utf16le')).toBe('alice');
+ });
+});