From b0104591ac7606962eba04febe6408dc2b6de277 Mon Sep 17 00:00:00 2001 From: cmliu Date: Tue, 28 Jul 2026 23:04:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20SSH=20=E9=94=AE?= =?UTF-8?q?=E7=9B=98=E4=BA=A4=E4=BA=92=E8=AE=A4=E8=AF=81=EF=BC=8C=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E7=9B=B8=E5=85=B3=E9=80=BB=E8=BE=91=E5=92=8C=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 - frontend/src/main.ts | 24 +++++-- src/backend/session.ts | 44 ++++++------ src/ssh/auth-password.ts | 97 ++++++++++++++++++++++++-- src/ssh/auth.ts | 24 +++++-- tests/auth.test.ts | 142 +++++++++++++++++++++++++++++++++++++++ tests/host-key.test.ts | 30 +++++++++ 7 files changed, 325 insertions(+), 37 deletions(-) create mode 100644 tests/auth.test.ts create mode 100644 tests/host-key.test.ts diff --git a/.gitignore b/.gitignore index a889fc5..13b6c96 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,3 @@ coverage/ .env* !.env.example .workbuddy/ -tests/ \ No newline at end of file diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 7e882e1..1a627f2 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -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 { 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 }> { diff --git a/src/backend/session.ts b/src/backend/session.ts index cbba221..66f476c 100644 --- a/src/backend/session.ts +++ b/src/backend/session.ts @@ -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; diff --git a/src/ssh/auth-password.ts b/src/ssh/auth-password.ts index 4eaaf31..c08f71e 100644 --- a/src/ssh/auth-password.ts +++ b/src/ssh/auth-password.ts @@ -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(); } diff --git a/src/ssh/auth.ts b/src/ssh/auth.ts index 59fd821..1afd762 100644 --- a/src/ssh/auth.ts +++ b/src/ssh/auth.ts @@ -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). */ diff --git a/tests/auth.test.ts b/tests/auth.test.ts new file mode 100644 index 0000000..66b30ec --- /dev/null +++ b/tests/auth.test.ts @@ -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/); +}); diff --git a/tests/host-key.test.ts b/tests/host-key.test.ts new file mode 100644 index 0000000..133977e --- /dev/null +++ b/tests/host-key.test.ts @@ -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'); +}); +