feat: 实现 SSH 键盘交互认证,增加相关逻辑和测试用例

This commit is contained in:
cmliu committed 2026-07-28 23:04:46 +08:00
1 parent c040c1b9fd
commit b0104591ac
7 files changed
+325 -37

No files matched your search

-1
View File
@@ -7,4 +7,3 @@ coverage/
.env*
!.env.example
.workbuddy/
tests/
+20 -4
View File
@@ -110,6 +110,8 @@ const LANGUAGE_STORAGE_KEY = 'workers-webssh.language';
const MAX_KEY_BYTES = 65_536;
const MAX_LEGACY_PASSWORD_BASE64_LENGTH = 16_384;
const PING_INTERVAL_MS = 25_000;
const CLIENT_CLOSE_SESSION_ERROR = 4000;
const CLIENT_CLOSE_PROTOCOL_ERROR = 4002;
function loadLanguage(): Language {
try {
@@ -1249,7 +1251,7 @@ function handleServerMessage(message: ServerMessage): void {
|| (expected !== '' && !SSH_FINGERPRINT_RE.test(expected))
|| (message.expectedFingerprint !== undefined && message.expectedFingerprint !== currentExpectedFingerprint)) {
event(bilingual('收到无效的主机密钥消息。', 'Received an invalid host key message.'), 'protocol', true);
socket?.close(1002, 'Invalid host key message');
socket?.close(CLIENT_CLOSE_PROTOCOL_ERROR, 'Invalid host key message');
return;
}
ui.metricHostKey.textContent = keyType.replace('ssh-', '').replace('ecdsa-sha2-', '');
@@ -1257,7 +1259,7 @@ function handleServerMessage(message: ServerMessage): void {
const trust = classifyHostKey(expected, fingerprint);
if (message.trusted !== (trust === 'matched')) {
event(bilingual('收到不一致的主机密钥信任消息。', 'Received an inconsistent trusted host key message.'), 'protocol', true);
socket?.close(1002, 'Invalid trusted host key message');
socket?.close(CLIENT_CLOSE_PROTOCOL_ERROR, 'Invalid trusted host key message');
return;
}
if (trust === 'matched') {
@@ -1309,10 +1311,24 @@ function handleServerMessage(message: ServerMessage): void {
async function handleSocketData(data: string | ArrayBuffer | Blob, activeSocket: WebSocket, generation: number): Promise<void> {
if (socket !== activeSocket || generation !== connectGeneration) return;
if (typeof data === 'string') {
let parsed: unknown;
try {
handleServerMessage(JSON.parse(data) as ServerMessage);
parsed = JSON.parse(data);
} catch {
event(bilingual('已忽略无效的控制帧。', 'Ignored an invalid control frame.'), 'protocol', true);
return;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
event(bilingual('已忽略无效的控制帧。', 'Ignored an invalid control frame.'), 'protocol', true);
return;
}
try {
handleServerMessage(parsed as ServerMessage);
} catch {
event(bilingual('处理服务器控制消息时发生错误。', 'Failed to process the server control message.'), 'protocol', true);
if (activeSocket.readyState < WebSocket.CLOSING) {
activeSocket.close(CLIENT_CLOSE_PROTOCOL_ERROR, 'Control message handling failed');
}
}
return;
}
@@ -1341,7 +1357,7 @@ function failActiveConnection(activeSocket: WebSocket | null, closeReason: strin
currentSessionSubtitle = displayReason;
ui.sessionSubtitle.textContent = localize(displayReason);
setState('error');
if (activeSocket && activeSocket.readyState < WebSocket.CLOSING) activeSocket.close(1011, closeReason);
if (activeSocket && activeSocket.readyState < WebSocket.CLOSING) activeSocket.close(CLIENT_CLOSE_SESSION_ERROR, closeReason);
}
async function issueTicket(signal: AbortSignal): Promise<{ ticket: string; sessionId: string }> {
+22 -22
View File
@@ -87,6 +87,8 @@ export class SSHSession {
private serverSigAlgs: string[] = [];
private authRequestSent = false;
private passwordAuthMethod: 'password' | 'keyboard-interactive' = 'password';
private keyboardInteractiveRounds = 0;
private keyboardInteractivePasswordSent = false;
private pendingChannelRequest: 'pty' | 'shell' | null = null;
private ignoreNextKexPacket = false;
private inputQueue: Uint8Array[] = [];
@@ -490,8 +492,26 @@ export class SSHSession {
if (this.config.authMethod !== 'password' || this.passwordAuthMethod !== 'keyboard-interactive' || !this.authRequestSent) {
throw new Error('Unexpected SSH keyboard-interactive challenge');
}
const promptCount = this.validateKeyboardInteractiveRequest(payload);
const response = SSHAuth.buildKeyboardInteractiveResponse(this.config.password!, promptCount);
this.keyboardInteractiveRounds++;
if (this.keyboardInteractiveRounds > 16) throw new Error('Too many SSH keyboard-interactive challenge rounds');
const challenge = SSHAuth.parseKeyboardInteractiveChallenge(payload);
let responses: string[];
if (challenge.prompts.length === 0) {
responses = [];
} else {
if (this.keyboardInteractivePasswordSent || this.config.password === undefined) {
throw new Error('SSH keyboard-interactive requested credentials more than once');
}
responses = SSHAuth.passwordResponsesForChallenge(challenge, this.config.password, {
username: this.config.username,
host: this.config.host,
});
}
const response = SSHAuth.buildKeyboardInteractiveResponse(responses);
if (responses.length > 0) {
this.keyboardInteractivePasswordSent = true;
this.config.password = undefined;
}
await this.sendEncrypted(response);
return;
}
@@ -784,26 +804,6 @@ export class SSHSession {
return methods ? methods.split(',') : [];
}
private validateKeyboardInteractiveRequest(payload: Uint8Array): number {
let offset = 1;
offset = this.readString(payload, offset).next; // name
offset = this.readString(payload, offset).next; // instruction
offset = this.readString(payload, offset).next; // language tag
if (offset + 4 > payload.length) throw new Error('Malformed SSH keyboard-interactive challenge');
const promptCount = readUint32(payload, offset);
offset += 4;
if (promptCount !== 1) throw new Error('SSH keyboard-interactive authentication requires exactly one prompt');
const prompt = this.readString(payload, offset);
offset = prompt.next;
if (offset >= payload.length || payload[offset] > 1 || offset + 1 !== payload.length) {
throw new Error('Malformed SSH keyboard-interactive prompt');
}
if (!SSHAuth.isSupportedPasswordPrompt(prompt.value, payload[offset] === 1)) {
throw new Error('SSH keyboard-interactive prompt is not a supported password prompt');
}
return promptCount;
}
private isHostKeyAlgorithmCompatible(negotiated: string | null, keyType: string): boolean {
if (!negotiated) return false;
if (negotiated === keyType) return true;
+91 -6
View File
@@ -1,5 +1,23 @@
const SSH_MSG_USERAUTH_REQUEST = 50;
const SSH_MSG_USERAUTH_INFO_RESPONSE = 61;
const MAX_KEYBOARD_INTERACTIVE_PROMPTS = 16;
export interface KeyboardInteractivePrompt {
text: string;
echo: boolean;
}
export interface KeyboardInteractiveChallenge {
name: string;
instruction: string;
language: string;
prompts: KeyboardInteractivePrompt[];
}
export interface PasswordPromptIdentity {
username: string;
host: string;
}
function concat(...arrays: Uint8Array[]): Uint8Array {
const result = new Uint8Array(arrays.reduce((total, value) => total + value.length, 0));
@@ -18,6 +36,25 @@ function encodeString(value: string): Uint8Array {
return concat(length, data);
}
function readUint32(data: Uint8Array, offset: number): number {
if (offset + 4 > data.length) throw new Error('Malformed SSH keyboard-interactive challenge');
return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(offset, false);
}
function readString(data: Uint8Array, offset: number): { value: string; next: number } {
const length = readUint32(data, offset);
const start = offset + 4;
if (length > data.length - start) throw new Error('Malformed SSH keyboard-interactive challenge');
try {
return {
value: new TextDecoder('utf-8', { fatal: true }).decode(data.subarray(start, start + length)),
next: start + length,
};
} catch {
throw new Error('Malformed SSH keyboard-interactive text');
}
}
export function buildPasswordAuthRequest(username: string, password: string): Uint8Array {
return concat(
new Uint8Array([SSH_MSG_USERAUTH_REQUEST]),
@@ -40,15 +77,63 @@ export function buildKeyboardInteractiveAuthRequest(username: string): Uint8Arra
);
}
export function buildKeyboardInteractiveResponse(password: string, promptCount: number): Uint8Array {
if (promptCount !== 1) throw new Error('SSH keyboard-interactive authentication requires exactly one prompt');
export function parseKeyboardInteractiveChallenge(payload: Uint8Array): KeyboardInteractiveChallenge {
if (payload[0] !== 60) throw new Error('Malformed SSH keyboard-interactive message type');
let offset = 1;
const name = readString(payload, offset); offset = name.next;
const instruction = readString(payload, offset); offset = instruction.next;
const language = readString(payload, offset); offset = language.next;
const promptCount = readUint32(payload, offset); offset += 4;
if (promptCount > MAX_KEYBOARD_INTERACTIVE_PROMPTS) {
throw new Error('SSH keyboard-interactive challenge has too many prompts');
}
const prompts: KeyboardInteractivePrompt[] = [];
for (let index = 0; index < promptCount; index++) {
const prompt = readString(payload, offset); offset = prompt.next;
if (prompt.value.length === 0 || offset >= payload.length || payload[offset] > 1) {
throw new Error('Malformed SSH keyboard-interactive prompt');
}
prompts.push({ text: prompt.value, echo: payload[offset] === 1 });
offset++;
}
if (offset !== payload.length) throw new Error('Malformed SSH keyboard-interactive trailing data');
return { name: name.value, instruction: instruction.value, language: language.value, prompts };
}
export function passwordResponsesForChallenge(
challenge: KeyboardInteractiveChallenge,
password: string,
identity: PasswordPromptIdentity,
): string[] {
if (challenge.prompts.length === 0) return [];
if (challenge.prompts.length === 1 && isSupportedPasswordPrompt(challenge, identity)) {
return [password];
}
throw new Error(`SSH keyboard-interactive challenge is not a supported password prompt (${challenge.prompts.length} prompts)`);
}
export function buildKeyboardInteractiveResponse(responses: string[]): Uint8Array {
const count = new Uint8Array(4);
new DataView(count.buffer).setUint32(0, responses.length, false);
return concat(
new Uint8Array([SSH_MSG_USERAUTH_INFO_RESPONSE]),
new Uint8Array([0, 0, 0, 1]),
encodeString(password),
count,
...responses.map(encodeString),
);
}
export function isSupportedPasswordPrompt(prompt: string, echo: boolean): boolean {
return !echo && /(?:password|passphrase|密码|口令)/i.test(prompt);
export function isSupportedPasswordPrompt(
challenge: KeyboardInteractiveChallenge,
identity: PasswordPromptIdentity,
): boolean {
if (challenge.prompts.length !== 1 || challenge.prompts[0].echo) return false;
// Automatic credential reuse is deliberately limited to an unambiguous,
// context-free password request for this exact SSH target.
if (challenge.name.trim() || challenge.instruction.trim()) return false;
const prompt = challenge.prompts[0].text;
if (/[\u0000-\u001f\u007f-\u009f]/u.test(prompt)) return false;
const normalized = prompt.trim();
if (/^(?:password|密码|口令)\s*[::]$/iu.test(normalized)) return true;
const match = /^password for (.+)@(.+):$/iu.exec(normalized);
return match !== null && match[1] === identity.username && match[2].toLowerCase() === identity.host.toLowerCase();
}
+20 -4
View File
@@ -5,6 +5,10 @@ import {
buildKeyboardInteractiveResponse,
buildPasswordAuthRequest,
isSupportedPasswordPrompt,
parseKeyboardInteractiveChallenge,
passwordResponsesForChallenge,
type KeyboardInteractiveChallenge,
type PasswordPromptIdentity,
} from './auth-password';
// SSH key type constants
@@ -40,12 +44,24 @@ export class SSHAuth {
return buildKeyboardInteractiveAuthRequest(username);
}
static buildKeyboardInteractiveResponse(password: string, promptCount: number): Uint8Array {
return buildKeyboardInteractiveResponse(password, promptCount);
static parseKeyboardInteractiveChallenge(payload: Uint8Array): KeyboardInteractiveChallenge {
return parseKeyboardInteractiveChallenge(payload);
}
static isSupportedPasswordPrompt(prompt: string, echo: boolean): boolean {
return isSupportedPasswordPrompt(prompt, echo);
static passwordResponsesForChallenge(
challenge: KeyboardInteractiveChallenge,
password: string,
identity: PasswordPromptIdentity,
): string[] {
return passwordResponsesForChallenge(challenge, password, identity);
}
static buildKeyboardInteractiveResponse(responses: string[]): Uint8Array {
return buildKeyboardInteractiveResponse(responses);
}
static isSupportedPasswordPrompt(challenge: KeyboardInteractiveChallenge, identity: PasswordPromptIdentity): boolean {
return isSupportedPasswordPrompt(challenge, identity);
}
/** Build a signed public-key authentication request (RFC 4252 section 7). */
+142
View File
@@ -0,0 +1,142 @@
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/);
});
+30
View File
@@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
classifyHostKey as classifyBrowserHostKey,
SSH_FINGERPRINT_RE,
} from '../frontend/src/host-key.ts';
import { classifyHostKey as classifyWorkerHostKey } from '../src/ssh/host-key.ts';
const OLD = 'SHA256:McWyGS5cvgt9EvFLQxxLOTPDqAY1MmKx3iAhgXUpEiY';
const CURRENT = 'SHA256:xs49vJHNJGmereADhVfIZMt1xdeY04wYkj5FpDpLR9c';
test('validates OpenSSH SHA-256 fingerprints', () => {
assert.equal(SSH_FINGERPRINT_RE.test(OLD), true);
assert.equal(SSH_FINGERPRINT_RE.test(CURRENT), true);
assert.equal(SSH_FINGERPRINT_RE.test(`${CURRENT}=`), false);
assert.equal(SSH_FINGERPRINT_RE.test('SHA256:not-a-fingerprint'), false);
});
test('browser prompts only for first-seen or changed host keys', () => {
assert.equal(classifyBrowserHostKey('', CURRENT), 'first-seen');
assert.equal(classifyBrowserHostKey(CURRENT, CURRENT), 'matched');
assert.equal(classifyBrowserHostKey(OLD, CURRENT), 'changed');
});
test('worker never treats a changed host key as already trusted', () => {
assert.equal(classifyWorkerHostKey(undefined, CURRENT), 'first-seen');
assert.equal(classifyWorkerHostKey(CURRENT, CURRENT), 'trusted');
assert.equal(classifyWorkerHostKey(OLD, CURRENT), 'changed');
});