Files
CloudSSH/tests/ssh/auth-keyboard-interactive.test.ts
TanXinandTanXin 4eeb050576 release: 发布 v1.10.0 自定义命令片段库版本 (#93)
合并 `test` 的 v1.10.0 提交到 `main`。

**包含提交**
- `28c9433` feat: 添加自定义命令片段库
- `9aa2e80` release: 发布 v1.10.0 自定义命令片段库版本

**关联**
- Related to #90

> 本 PR 仅关联 #90,不自动关闭该 Issue。

**验证**
- `pnpm run typecheck` 0 错
- `pnpm test` 47/47 603/603 通过
- `pnpm run build:frontend` 成功

---------

Co-authored-by: TanXin <tanxin@example.com>
2026-08-20 11:42:28 +08:00

306 lines
11 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { SSHAuth } from '../../src/ssh/auth';
import { concat, encodeString, encodeUint32, readUint32 } from '../../src/ssh/utils';
function buildInfoRequest(
name: string,
instruction: string,
language: string,
prompts: Array<{ text: string; echo: boolean }>
): Uint8Array {
return concat(
new Uint8Array([60]),
encodeString(name),
encodeString(instruction),
encodeString(language),
encodeUint32(prompts.length),
...prompts.flatMap(({ text, echo }) => [encodeString(text), new Uint8Array([echo ? 1 : 0])])
);
}
function readString(payload: Uint8Array, offset: number): { value: string; nextOffset: number } {
const length = readUint32(payload, offset);
const valueOffset = offset + 4;
return {
value: new TextDecoder().decode(payload.subarray(valueOffset, valueOffset + length)),
nextOffset: valueOffset + length,
};
}
describe('SSHAuth keyboard-interactive (RFC 4256)', () => {
describe('buildNoneAuthRequest', () => {
it('encodes an RFC 4252 method-discovery request without credentials', () => {
const packet = SSHAuth.buildNoneAuthRequest('alice');
expect(packet[0]).toBe(50);
let offset = 1;
const username = readString(packet, offset);
offset = username.nextOffset;
const service = readString(packet, offset);
offset = service.nextOffset;
const method = readString(packet, offset);
offset = method.nextOffset;
expect({ username: username.value, service: service.value, method: method.value }).toEqual({
username: 'alice',
service: 'ssh-connection',
method: 'none',
});
expect(offset).toBe(packet.length);
expect(new TextDecoder().decode(packet)).not.toContain('saved-secret');
});
});
describe('buildKeyboardInteractiveAuthRequest', () => {
it('encodes the username, service, method, language and submethods', () => {
const packet = SSHAuth.buildKeyboardInteractiveAuthRequest('用户@example');
expect(packet[0]).toBe(50);
let offset = 1;
const username = readString(packet, offset);
offset = username.nextOffset;
const service = readString(packet, offset);
offset = service.nextOffset;
const method = readString(packet, offset);
offset = method.nextOffset;
const language = readString(packet, offset);
offset = language.nextOffset;
const submethods = readString(packet, offset);
offset = submethods.nextOffset;
expect({
username: username.value,
service: service.value,
method: method.value,
language: language.value,
submethods: submethods.value,
}).toEqual({
username: '用户@example',
service: 'ssh-connection',
method: 'keyboard-interactive',
language: '',
submethods: '',
});
expect(offset).toBe(packet.length);
});
});
describe('parseKeyboardInteractiveInfoRequest', () => {
it('parses a single hidden prompt and Unicode server text', () => {
const packet = buildInfoRequest('双因素认证', '请输入密码或动态验证码。', 'zh-CN', [
{ text: '密码:', echo: false },
]);
expect(SSHAuth.parseKeyboardInteractiveInfoRequest(packet)).toEqual({
name: '双因素认证',
instruction: '请输入密码或动态验证码。',
language: 'zh-CN',
prompts: [{ text: '密码:', echo: false }],
});
});
it('parses multiple prompts with independent echo flags', () => {
const packet = buildInfoRequest('Login', 'Complete all fields', '', [
{ text: 'Account: ', echo: true },
{ text: 'Password: ', echo: false },
{ text: 'OTP: ', echo: false },
]);
expect(SSHAuth.parseKeyboardInteractiveInfoRequest(packet).prompts).toEqual([
{ text: 'Account: ', echo: true },
{ text: 'Password: ', echo: false },
{ text: 'OTP: ', echo: false },
]);
});
it('accepts a zero-prompt informational round', () => {
const packet = buildInfoRequest('Notice', 'Authentication continues', '', []);
expect(SSHAuth.parseKeyboardInteractiveInfoRequest(packet)).toEqual({
name: 'Notice',
instruction: 'Authentication continues',
language: '',
prompts: [],
});
});
it('rejects the wrong message type and empty payloads', () => {
expect(() => SSHAuth.parseKeyboardInteractiveInfoRequest(new Uint8Array())).toThrow(
'Unexpected keyboard-interactive message type'
);
expect(() => SSHAuth.parseKeyboardInteractiveInfoRequest(new Uint8Array([61]))).toThrow(
'Unexpected keyboard-interactive message type'
);
});
it('rejects truncated strings, counts and prompt echo flags', () => {
expect(() => SSHAuth.parseKeyboardInteractiveInfoRequest(new Uint8Array([60, 0, 0]))).toThrow(
/truncated name length/
);
const truncatedName = concat(
new Uint8Array([60]),
encodeUint32(4),
new TextEncoder().encode('ab')
);
expect(() => SSHAuth.parseKeyboardInteractiveInfoRequest(truncatedName)).toThrow(
/truncated name$/
);
const missingCount = concat(
new Uint8Array([60]),
encodeString(''),
encodeString(''),
encodeString(''),
new Uint8Array([0, 0])
);
expect(() => SSHAuth.parseKeyboardInteractiveInfoRequest(missingCount)).toThrow(
/truncated prompt count/
);
const missingEcho = concat(
new Uint8Array([60]),
encodeString(''),
encodeString(''),
encodeString(''),
encodeUint32(1),
encodeString('Password: ')
);
expect(() => SSHAuth.parseKeyboardInteractiveInfoRequest(missingEcho)).toThrow(
/missing echo flag/
);
});
it('rejects invalid UTF-8, invalid booleans and trailing data', () => {
const invalidUtf8 = concat(
new Uint8Array([60]),
encodeString(new Uint8Array([0xc3, 0x28])),
encodeString(''),
encodeString(''),
encodeUint32(0)
);
expect(() => SSHAuth.parseKeyboardInteractiveInfoRequest(invalidUtf8)).toThrow(
/invalid UTF-8 in name/
);
const invalidEcho = buildInfoRequest('', '', '', [{ text: 'Code: ', echo: false }]);
invalidEcho[invalidEcho.length - 1] = 2;
expect(() => SSHAuth.parseKeyboardInteractiveInfoRequest(invalidEcho)).toThrow(
/invalid echo flag/
);
const trailing = concat(buildInfoRequest('', '', '', []), new Uint8Array([0]));
expect(() => SSHAuth.parseKeyboardInteractiveInfoRequest(trailing)).toThrow(/trailing data/);
const emptyPrompt = buildInfoRequest('', '', '', [{ text: '', echo: false }]);
expect(() => SSHAuth.parseKeyboardInteractiveInfoRequest(emptyPrompt)).toThrow(
/prompt 1 is empty/
);
});
it('rejects excessive prompt counts, field sizes and packet sizes', () => {
const tooManyPrompts = concat(
new Uint8Array([60]),
encodeString(''),
encodeString(''),
encodeString(''),
encodeUint32(33)
);
expect(() => SSHAuth.parseKeyboardInteractiveInfoRequest(tooManyPrompts)).toThrow(
/too many prompts/
);
const oversizedPrompt = buildInfoRequest('', '', '', [
{ text: 'x'.repeat(16 * 1024 + 1), echo: false },
]);
expect(() => SSHAuth.parseKeyboardInteractiveInfoRequest(oversizedPrompt)).toThrow(
/prompt 1 exceeds size limit/
);
const oversizedPacket = new Uint8Array(256 * 1024 + 1);
oversizedPacket[0] = 60;
expect(() => SSHAuth.parseKeyboardInteractiveInfoRequest(oversizedPacket)).toThrow(
/packet exceeds size limit/
);
});
});
describe('buildKeyboardInteractiveInfoResponse', () => {
it('encodes zero, multiple and Unicode responses', () => {
const empty = SSHAuth.buildKeyboardInteractiveInfoResponse([]);
expect(empty).toEqual(new Uint8Array([61, 0, 0, 0, 0]));
const packet = SSHAuth.buildKeyboardInteractiveInfoResponse(['alice', '密碼🔐']);
expect(packet[0]).toBe(61);
expect(readUint32(packet, 1)).toBe(2);
let offset = 5;
const first = readString(packet, offset);
offset = first.nextOffset;
const second = readString(packet, offset);
offset = second.nextOffset;
expect([first.value, second.value]).toEqual(['alice', '密碼🔐']);
expect(offset).toBe(packet.length);
});
it('rejects invalid response counts, values and sizes without exposing values', () => {
expect(() => SSHAuth.buildKeyboardInteractiveInfoResponse(new Array(33).fill(''))).toThrow(
/too many responses/
);
expect(() =>
SSHAuth.buildKeyboardInteractiveInfoResponse([123 as unknown as string])
).toThrow(/every response must be a string/);
expect(() =>
SSHAuth.buildKeyboardInteractiveInfoResponse(['s'.repeat(64 * 1024 + 1)])
).toThrow(/response exceeds size limit/);
expect(() =>
SSHAuth.buildKeyboardInteractiveInfoResponse(new Array(5).fill('s'.repeat(64 * 1024)))
).toThrow(/packet exceeds size limit/);
});
});
describe('USERAUTH_FAILURE partial success', () => {
it('parses standards-compliant true and false values', () => {
const partial = concat(
new Uint8Array([51]),
encodeString('publickey,keyboard-interactive'),
new Uint8Array([1])
);
const notPartial = concat(
new Uint8Array([51]),
encodeString('password'),
new Uint8Array([0])
);
expect(SSHAuth.handleResponse(partial)).toEqual({
success: false,
allowedMethods: ['publickey', 'keyboard-interactive'],
partialSuccess: true,
});
expect(SSHAuth.handleResponse(notPartial).partialSuccess).toBe(false);
});
it('requires the RFC 4252 partial-success boolean', () => {
const truncated = concat(new Uint8Array([51]), encodeString('password'));
expect(() => SSHAuth.handleResponse(truncated)).toThrow(/missing partial success flag/);
});
it('rejects malformed partial-success flags and trailing bytes', () => {
const invalidFlag = concat(
new Uint8Array([51]),
encodeString('password'),
new Uint8Array([2])
);
const trailing = concat(
new Uint8Array([51]),
encodeString('password'),
new Uint8Array([0, 0])
);
expect(() => SSHAuth.handleResponse(invalidFlag)).toThrow(/invalid partial success flag/);
expect(() => SSHAuth.handleResponse(trailing)).toThrow(/trailing data/);
});
});
});