143 lines
6.1 KiB
TypeScript
143 lines
6.1 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
buildKeyboardInteractiveAuthRequest,
|
|
buildKeyboardInteractiveResponse,
|
|
isSupportedPasswordPrompt,
|
|
parseKeyboardInteractiveChallenge,
|
|
passwordResponsesForChallenge,
|
|
} from '../src/ssh/auth-password.ts';
|
|
|
|
const IDENTITY = { username: 'alice', host: 'example.com' };
|
|
|
|
interface ChallengeFields {
|
|
name?: string;
|
|
instruction?: string;
|
|
language?: string;
|
|
}
|
|
|
|
function encodeString(value: string): Uint8Array {
|
|
const bytes = new TextEncoder().encode(value);
|
|
const result = new Uint8Array(4 + bytes.length);
|
|
new DataView(result.buffer).setUint32(0, bytes.length, false);
|
|
result.set(bytes, 4);
|
|
return result;
|
|
}
|
|
|
|
function challenge(prompts: Array<{ text: string; echo: boolean }>, fields: ChallengeFields = {}): Uint8Array {
|
|
const count = new Uint8Array(4);
|
|
new DataView(count.buffer).setUint32(0, prompts.length, false);
|
|
const parts = [
|
|
new Uint8Array([60]),
|
|
encodeString(fields.name ?? ''),
|
|
encodeString(fields.instruction ?? ''),
|
|
encodeString(fields.language ?? ''),
|
|
count,
|
|
...prompts.flatMap((prompt) => [encodeString(prompt.text), new Uint8Array([prompt.echo ? 1 : 0])]),
|
|
];
|
|
const result = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
|
|
let offset = 0;
|
|
for (const part of parts) { result.set(part, offset); offset += part.length; }
|
|
return result;
|
|
}
|
|
|
|
function readString(data: Uint8Array, offset: number): { value: string; next: number } {
|
|
const length = readUint32(data, offset);
|
|
const start = offset + 4;
|
|
return { value: new TextDecoder().decode(data.subarray(start, start + length)), next: start + length };
|
|
}
|
|
|
|
function readUint32(data: Uint8Array, offset: number): number {
|
|
return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(offset, false);
|
|
}
|
|
|
|
test('builds an RFC 4256 keyboard-interactive request without the password', () => {
|
|
const request = buildKeyboardInteractiveAuthRequest('coss');
|
|
assert.equal(request[0], 50);
|
|
let field = readString(request, 1);
|
|
assert.equal(field.value, 'coss');
|
|
field = readString(request, field.next);
|
|
assert.equal(field.value, 'ssh-connection');
|
|
field = readString(request, field.next);
|
|
assert.equal(field.value, 'keyboard-interactive');
|
|
field = readString(request, field.next);
|
|
assert.equal(field.value, '');
|
|
field = readString(request, field.next);
|
|
assert.equal(field.value, '');
|
|
assert.equal(field.next, request.length);
|
|
assert.equal(new TextDecoder().decode(request).includes('secret'), false);
|
|
});
|
|
|
|
test('answers a password prompt', () => {
|
|
const parsed = parseKeyboardInteractiveChallenge(challenge([{
|
|
text: 'Password for alice@example.com:',
|
|
echo: false,
|
|
}]));
|
|
const response = buildKeyboardInteractiveResponse(passwordResponsesForChallenge(parsed, 'test-secret', IDENTITY));
|
|
assert.equal(response[0], 61);
|
|
assert.equal(readUint32(response, 1), 1);
|
|
const answer = readString(response, 5);
|
|
assert.equal(answer.value, 'test-secret');
|
|
assert.equal(answer.next, response.length);
|
|
});
|
|
|
|
test('acknowledges zero-prompt keyboard-interactive notices', () => {
|
|
const parsed = parseKeyboardInteractiveChallenge(challenge([], { instruction: 'Authentication successful' }));
|
|
assert.deepEqual(parsed.prompts, []);
|
|
const response = buildKeyboardInteractiveResponse(passwordResponsesForChallenge(parsed, 'test-secret', IDENTITY));
|
|
assert.equal(response[0], 61);
|
|
assert.equal(readUint32(response, 1), 0);
|
|
assert.equal(response.length, 5);
|
|
});
|
|
|
|
test('rejects multiple or non-password prompts', () => {
|
|
const multiple = parseKeyboardInteractiveChallenge(challenge([
|
|
{ text: 'Password:', echo: false },
|
|
{ text: 'Verification code:', echo: false },
|
|
]));
|
|
assert.throws(() => passwordResponsesForChallenge(multiple, 'test-secret', IDENTITY), /2 prompts/);
|
|
const otp = parseKeyboardInteractiveChallenge(challenge([{ text: 'Verification code:', echo: false }]));
|
|
assert.throws(() => passwordResponsesForChallenge(otp, 'test-secret', IDENTITY), /not a supported password prompt/);
|
|
});
|
|
|
|
test('only auto-answers hidden password prompts', () => {
|
|
const supported = (text: string, echo = false, fields: ChallengeFields = {}) => isSupportedPasswordPrompt(
|
|
parseKeyboardInteractiveChallenge(challenge([{ text, echo }], fields)),
|
|
IDENTITY,
|
|
);
|
|
assert.equal(supported('Password:'), true);
|
|
assert.equal(supported('密码:'), true);
|
|
assert.equal(supported('Password for alice@example.com:'), true);
|
|
assert.equal(supported('password for alice@EXAMPLE.COM:'), true);
|
|
assert.equal(supported('Password for mallory@example.com:'), false);
|
|
assert.equal(supported('Password:', true), false);
|
|
assert.equal(supported('Verification code:'), false);
|
|
assert.equal(supported('Do not enter your password; enter OTP:'), false);
|
|
assert.equal(supported('Enter new password:'), false);
|
|
assert.equal(supported('Password + verification code:'), false);
|
|
assert.equal(supported('Passphrase for key:'), false);
|
|
assert.equal(supported('Password:', false, { instruction: 'Enter your one-time code' }), false);
|
|
assert.equal(supported('Password:', false, { name: 'Second factor' }), false);
|
|
});
|
|
|
|
test('strictly validates keyboard-interactive challenge framing', () => {
|
|
assert.throws(() => parseKeyboardInteractiveChallenge(challenge([{ text: '', echo: false }])), /Malformed/);
|
|
|
|
const badBoolean = challenge([{ text: 'Password:', echo: false }]);
|
|
badBoolean[badBoolean.length - 1] = 2;
|
|
assert.throws(() => parseKeyboardInteractiveChallenge(badBoolean), /Malformed/);
|
|
|
|
const invalidUtf8 = challenge([], { name: 'x' });
|
|
invalidUtf8[5] = 0xff;
|
|
assert.throws(() => parseKeyboardInteractiveChallenge(invalidUtf8), /text/);
|
|
|
|
const valid = challenge([{ text: 'Password:', echo: false }]);
|
|
const trailing = new Uint8Array(valid.length + 1);
|
|
trailing.set(valid);
|
|
assert.throws(() => parseKeyboardInteractiveChallenge(trailing), /trailing data/);
|
|
assert.throws(() => parseKeyboardInteractiveChallenge(valid.subarray(0, valid.length - 1)), /Malformed/);
|
|
|
|
const tooMany = Array.from({ length: 17 }, () => ({ text: 'Password:', echo: false }));
|
|
assert.throws(() => parseKeyboardInteractiveChallenge(challenge(tooMany)), /too many prompts/);
|
|
});
|