From c040c1b9fdab1840ae55172035c8a5cd5ff33890 Mon Sep 17 00:00:00 2001 From: cmliu Date: Tue, 28 Jul 2026 22:19:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=20SSH=20=E4=B8=BB?= =?UTF-8?q?=E6=9C=BA=E5=AF=86=E9=92=A5=E9=AA=8C=E8=AF=81=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E9=94=AE=E7=9B=98=E4=BA=A4=E4=BA=92=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E5=B9=B6=E4=BC=98=E5=8C=96=20UI=20=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + README.md | 4 +- frontend/index.html | 16 ++--- frontend/src/host-key.ts | 19 ++++++ frontend/src/main.ts | 138 +++++++++++++++++++++++++++++++++------ frontend/src/style.css | 25 +++++++ package.json | 3 +- src/backend/session.ts | 78 +++++++++++++++++++--- src/ssh/auth-password.ts | 54 +++++++++++++++ src/ssh/auth.ts | 27 +++++--- src/ssh/host-key.ts | 10 +++ src/types.ts | 1 + 12 files changed, 325 insertions(+), 51 deletions(-) create mode 100644 frontend/src/host-key.ts create mode 100644 src/ssh/auth-password.ts create mode 100644 src/ssh/host-key.ts diff --git a/.gitignore b/.gitignore index 13b6c96..a889fc5 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ coverage/ .env* !.env.example .workbuddy/ +tests/ \ No newline at end of file diff --git a/README.md b/README.md index 9f198b3..d78ceff 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ - Cloudflare Workers 原生部署,使用 Durable Objects 隔离每个 SSH 会话。 - 基于 xterm.js 的响应式终端,支持桌面端和移动端、自动缩放、全屏和会话日志。 -- 支持密码认证,以及 Ed25519、RSA、ECDSA 的未加密 OpenSSH 私钥认证。 +- 支持 SSH password 与单密码提示的 keyboard-interactive 认证,以及 Ed25519、RSA、ECDSA 的未加密 OpenSSH 私钥认证。 - 首次连接时暂停认证并显示主机 SHA-256 指纹,确认后才会发送 SSH 凭据。 - SSH 连接成功后自动写入浏览器本地"历史记录",密码使用 AES-256-GCM 加密,私钥不保存。 - 支持 UTF-8、GB18030、Big5 显示编码、初始命令和分享链接(密码认证时链接携带 Base64 密码,粘贴即自动连接)。 @@ -40,7 +40,7 @@ Cloudflare Worker | 类别 | 当前支持 | | --- | --- | | SSH 协议 | SSH 2.0 交互式 Shell、PTY、窗口尺寸同步、Keepalive | -| 用户认证 | Password、OpenSSH Ed25519、RSA、ECDSA P-256/P-384/P-521 私钥 | +| 用户认证 | Password、单密码提示 keyboard-interactive、OpenSSH Ed25519、RSA、ECDSA P-256/P-384/P-521 私钥 | | 密钥交换 | `curve25519-sha256`、`ecdh-sha2-nistp256` | | 主机密钥 | Ed25519、ECDSA P-256/P-384/P-521、RSA SHA-2 | | 加密算法 | AES-128/256-GCM、AES-128/192/256-CTR | diff --git a/frontend/index.html b/frontend/index.html index 51dc02a..c603bcd 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -294,27 +294,25 @@
- +
-

主机身份

-

信任此 SSH 主机?

-

服务器提供了尚未为此目标固定的密钥。继续前,请通过可信渠道核验。

+

主机身份

+

信任此 SSH 主机?

+

服务器提供了尚未为此目标固定的密钥。继续前,请通过可信渠道核验。

目标
--
密钥类型
--
+
SHA-256 指纹
--
- +
diff --git a/frontend/src/host-key.ts b/frontend/src/host-key.ts new file mode 100644 index 0000000..e4d52cc --- /dev/null +++ b/frontend/src/host-key.ts @@ -0,0 +1,19 @@ +export const SSH_FINGERPRINT_RE = /^SHA256:[A-Za-z0-9+/]{43}$/; + +export type HostKeyTrust = 'first-seen' | 'changed'; + +export interface HostKeyPrompt { + fingerprint: string; + keyType: string; + expectedFingerprint: string; + trust: HostKeyTrust; +} + +export function classifyHostKey( + expectedFingerprint: string, + fingerprint: string, +): 'matched' | HostKeyTrust { + if (!expectedFingerprint) return 'first-seen'; + return expectedFingerprint === fingerprint ? 'matched' : 'changed'; +} + diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 240c499..7e882e1 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -5,6 +5,7 @@ import { historyKey, historyLabel, normalizeHistory, upsertHistoryIfNewer, type import { getHistoryPasswordKey } from './history-key'; import { decryptPasswordResult, encryptPassword, isEncryptedPassword } from './password-crypto'; import { resolveConnectionControl, resolveConnectionPanel } from './ui-state'; +import { classifyHostKey, SSH_FINGERPRINT_RE, type HostKeyPrompt } from './host-key'; import './style.css'; type AuthMethod = 'password' | 'publickey'; @@ -62,6 +63,7 @@ interface ServerMessage { event?: string; message?: string; fingerprint?: string; + expectedFingerprint?: string; keyType?: string; trusted?: boolean; latency?: number; @@ -287,10 +289,17 @@ const ui = { eventLog: element('event-log'), toastRegion: element('toast-region'), hostKeyDialog: element('host-key-dialog'), + hostKeyIcon: element('host-key-icon'), + hostKeyEyebrow: element('host-key-eyebrow'), + hostKeyTitle: element('host-key-title'), + hostKeyDescription: element('host-key-description'), hostKeyTarget: element('host-key-target'), hostKeyType: element('host-key-type'), + hostKeyExpectedRow: element('host-key-expected-row'), + hostKeyExpectedFingerprint: element('host-key-expected-fingerprint'), hostKeyFingerprint: element('host-key-fingerprint'), rememberHostKey: element('remember-host-key'), + rememberHostKeyLabel: element('remember-host-key-label'), rejectHostKey: element('reject-host-key'), acceptHostKey: element('accept-host-key'), }; @@ -353,7 +362,7 @@ let sessionStartedAt = 0; let uptimeTimer: number | null = null; let pingTimer: number | null = null; let lastPingAt = 0; -let pendingHostKey: { fingerprint: string; keyType: string } | null = null; +let pendingHostKey: HostKeyPrompt | null = null; let currentTargetKey = ''; let currentTargetLabel = ''; let currentInitialCommand = ''; @@ -364,6 +373,7 @@ let awaitingHostKeyDecision = false; let connectGeneration = 0; let authorizationAbort: AbortController | null = null; let currentExpectedFingerprint = ''; +let currentRememberedFingerprint = ''; let currentSessionSubtitle: LocalizedMessage = { zh: '选择目标并连接', en: 'Choose a target and connect' }; let currentEventMessage: LocalizedMessage = { zh: 'Worker 运行时待命', en: 'Worker runtime standing by' }; let currentFormError: LocalizedMessage | null = null; @@ -442,7 +452,7 @@ function isStoredSavedProfile(value: unknown): value is StoredSavedProfile { typeof item.encoding === 'string' && ['utf-8', 'gb18030', 'big5'].includes(item.encoding) && typeof item.fingerprint === 'string' && - (item.fingerprint === '' || /^SHA256:[A-Za-z0-9+/]{43}$/.test(item.fingerprint)) && + (item.fingerprint === '' || SSH_FINGERPRINT_RE.test(item.fingerprint)) && typeof item.updatedAt === 'number' && Number.isFinite(item.updatedAt) && item.updatedAt >= 0 && @@ -486,7 +496,7 @@ function isLegacySavedProfile(value: unknown): value is LegacySavedProfile { && typeof item.initialCommand === 'string' && item.initialCommand.length <= 4096 && typeof item.termType === 'string' && /^[A-Za-z0-9._+-]{1,64}$/.test(item.termType) && typeof item.encoding === 'string' && ['utf-8', 'gb18030', 'big5'].includes(item.encoding) - && typeof item.fingerprint === 'string' && (item.fingerprint === '' || /^SHA256:[A-Za-z0-9+/]{43}$/.test(item.fingerprint)) + && typeof item.fingerprint === 'string' && (item.fingerprint === '' || SSH_FINGERPRINT_RE.test(item.fingerprint)) && typeof item.updatedAt === 'number' && Number.isFinite(item.updatedAt) && item.updatedAt >= 0 && item.updatedAt <= 8_640_000_000_000_000; } @@ -617,7 +627,7 @@ function loadHostKeys(): Record { const parsed: unknown = JSON.parse(localStorage.getItem(HOST_KEY_STORAGE_KEY) ?? '{}'); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; return Object.fromEntries( - Object.entries(parsed).filter(([key, value]) => key.length <= 512 && typeof value === 'string' && /^SHA256:[A-Za-z0-9+/]{43}$/.test(value)), + Object.entries(parsed).filter(([key, value]) => key.length <= 512 && typeof value === 'string' && SSH_FINGERPRINT_RE.test(value)), ); } catch { return {}; @@ -668,9 +678,52 @@ async function persistHistoryMutation(mutation: HistoryMutation): Promise { + const write = (): boolean => { + const snapshot = readStoredArray(PROFILE_STORAGE_KEY); + const stored = snapshot.values + .map(sanitizeSavedProfile) + .filter((profile): profile is SavedProfile => profile !== null); + const base = snapshot.present ? normalizeHistory(stored) : profiles; + const baseHostKeys = loadHostKeys(); + const updated = base.map((profile) => passwordContext(profile) === target + ? { ...profile, fingerprint } + : profile); + try { + localStorage.setItem(PROFILE_STORAGE_KEY, JSON.stringify(updated)); + hostKeys = { ...baseHostKeys, [target]: fingerprint }; + if (!persistHostKeys()) throw new Error('Host key persistence failed'); + profiles = updated; + return true; + } catch { + hostKeys = baseHostKeys; + try { localStorage.setItem(PROFILE_STORAGE_KEY, JSON.stringify(base)); } catch { /* Best-effort rollback. */ } + return false; + } + }; + let persisted = false; + try { + persisted = 'locks' in navigator + ? await navigator.locks.request('workers-webssh.profiles.v2', write) + : write(); + } catch { + persisted = false; + } + if (!persisted) toast(bilingual( + '当前连接已接受新指纹,但无法在此浏览器中记住。', + 'The new fingerprint was accepted for this connection but could not be remembered in this browser.', + ), 'error'); + renderProfiles(); } function authMethod(): AuthMethod { @@ -774,7 +827,7 @@ function validateProfileFields(): string | null { if (!Number.isInteger(port) || port < 1 || port > 65_535) return bilingual('端口必须介于 1 和 65535 之间。', 'Port must be between 1 and 65535.'); if (!username || username.length > 128 || /[\r\n\0]/.test(username)) return bilingual('请输入有效的 SSH 用户名。', 'Enter a valid SSH username.'); const fingerprint = ui.fingerprint.value.trim(); - if (fingerprint && !/^SHA256:[A-Za-z0-9+/]{43}$/.test(fingerprint)) return bilingual('主机指纹必须使用 SHA256:base64 格式。', 'Host fingerprint must use the SHA256:base64 format.'); + if (fingerprint && !SSH_FINGERPRINT_RE.test(fingerprint)) return bilingual('主机指纹必须使用 SHA256:base64 格式。', 'Host fingerprint must use the SHA256:base64 format.'); return null; } @@ -875,7 +928,9 @@ async function saveConnectedProfile(): Promise { } if (passwordContext(profile) !== operation.target || latestHistoryMutation.get(operation.target) !== mutation) return; const key = targetKey(profile.host, profile.port, profile.username); - const rememberedFingerprint = profile.fingerprint || hostKeys[key] || ''; + const rememberedFingerprint = key === currentTargetKey && currentRememberedFingerprint + ? currentRememberedFingerprint + : hostKeys[key] || profile.fingerprint || ''; const saved: SavedProfile = { ...profile, fingerprint: rememberedFingerprint, @@ -1127,15 +1182,46 @@ function sendHostKeyDecision(accept: boolean): void { fingerprint: hostKey.fingerprint, })); if (accept && ui.rememberHostKey.checked && currentTargetKey) { - hostKeys[currentTargetKey] = hostKey.fingerprint; - persistHostKeys(); + currentRememberedFingerprint = hostKey.fingerprint; + void replaceRememberedHostKey(currentTargetKey, hostKey.fingerprint); if (targetKey() === currentTargetKey) ui.fingerprint.value = hostKey.fingerprint; } event(accept - ? bilingual('主机密钥已接受,可以继续认证。', 'Host key accepted; authentication may continue.') + ? hostKey.trust === 'changed' + ? bilingual('新的主机密钥已明确接受,可以继续认证。', 'The new host key was explicitly accepted; authentication may continue.') + : bilingual('主机密钥已接受,可以继续认证。', 'Host key accepted; authentication may continue.') : bilingual('主机密钥已拒绝。', 'Host key rejected.'), 'host-key', !accept); } +function configureHostKeyDialog(hostKey: HostKeyPrompt): void { + const changed = hostKey.trust === 'changed'; + ui.hostKeyDialog.classList.toggle('changed', changed); + ui.hostKeyIcon.textContent = changed ? bilingual('警告', 'ALERT') : bilingual('密钥', 'KEY'); + ui.hostKeyEyebrow.textContent = changed + ? bilingual('主机身份已变化', 'HOST IDENTITY CHANGED') + : bilingual('主机身份', 'HOST IDENTITY'); + ui.hostKeyTitle.textContent = changed + ? bilingual('SSH 主机密钥不匹配', 'SSH host key mismatch') + : bilingual('信任此 SSH 主机?', 'Trust this SSH host?'); + ui.hostKeyDescription.textContent = changed + ? bilingual( + '服务器密钥与之前保存的指纹不同。这可能是正常换钥,也可能是中间人攻击。仅在通过可信渠道确认新指纹后继续。', + 'The server key differs from the saved fingerprint. This can be a legitimate rotation or a man-in-the-middle attack. Continue only after verifying the new fingerprint through a trusted channel.', + ) + : bilingual( + '服务器提供了尚未为此目标固定的密钥。继续前,请通过可信渠道核验。', + 'The server presented a key that has not been pinned for this target. Verify it through a trusted channel before continuing.', + ); + ui.hostKeyExpectedRow.hidden = !changed; + ui.hostKeyExpectedFingerprint.textContent = changed ? hostKey.expectedFingerprint : '--'; + ui.rememberHostKeyLabel.textContent = changed + ? bilingual('替换并记住这个新指纹', 'Replace the saved fingerprint with this new one') + : bilingual('下次记住并验证此指纹', 'Remember and verify this fingerprint next time'); + ui.acceptHostKey.textContent = changed + ? bilingual('我已核验,替换并继续', 'Verified: replace & continue') + : bilingual('信任并继续', 'Trust & continue'); +} + function clearHostKeyPrompt(): void { awaitingHostKeyDecision = false; pendingHostKey = null; @@ -1156,30 +1242,38 @@ function handleServerMessage(message: ServerMessage): void { if (type === 'host_key') { const fingerprint = message.fingerprint ?? ''; const keyType = message.keyType ?? ''; - if (!/^SHA256:[A-Za-z0-9+/]{43}$/.test(fingerprint) || !/^[A-Za-z0-9@._+-]{1,128}$/.test(keyType)) { + const expected = message.expectedFingerprint ?? currentExpectedFingerprint; + if (!SSH_FINGERPRINT_RE.test(fingerprint) + || !/^[A-Za-z0-9@._+-]{1,128}$/.test(keyType) + || typeof message.trusted !== 'boolean' + || (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'); return; } - pendingHostKey = { fingerprint, keyType }; ui.metricHostKey.textContent = keyType.replace('ssh-', '').replace('ecdsa-sha2-', ''); ui.metricHostKey.title = fingerprint; - const expected = currentExpectedFingerprint; - if (expected && expected !== fingerprint) { - event(bilingual('主机密钥不匹配,连接已拒绝。', 'Host key mismatch; connection rejected.'), 'host-key', true); - socket?.close(1008, 'Host key mismatch'); + 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'); return; } - if (message.trusted || expected) { + if (trust === 'matched') { event(bilingual('已固定的主机密钥匹配。', 'Pinned host key matched.'), 'host-key'); return; } + pendingHostKey = { fingerprint, keyType, expectedFingerprint: expected, trust }; awaitingHostKeyDecision = true; + configureHostKeyDialog(pendingHostKey); ui.hostKeyTarget.textContent = ui.sessionTitle.textContent ?? currentTargetKey; ui.hostKeyType.textContent = keyType; ui.hostKeyFingerprint.textContent = fingerprint; ui.rememberHostKey.checked = true; - event(bilingual(`认证已暂停,请确认首次见到的主机密钥 ${fingerprint}`, `Authentication paused for first-seen host key ${fingerprint}`), 'host-key'); + event(trust === 'changed' + ? bilingual(`认证已暂停:主机密钥从 ${expected} 变为 ${fingerprint}`, `Authentication paused: host key changed from ${expected} to ${fingerprint}`) + : bilingual(`认证已暂停,请确认首次见到的主机密钥 ${fingerprint}`, `Authentication paused for first-seen host key ${fingerprint}`), 'host-key', trust === 'changed'); if (!ui.hostKeyDialog.open) { ui.hostKeyDialog.returnValue = ''; ui.hostKeyDialog.showModal(); @@ -1240,6 +1334,7 @@ function failActiveConnection(activeSocket: WebSocket | null, closeReason: strin authorizationAbort?.abort(); authorizationAbort = null; currentExpectedFingerprint = ''; + currentRememberedFingerprint = ''; stopTimers(); clearHostKeyPrompt(); invalidateHistoryPasswordLoad(); @@ -1304,6 +1399,7 @@ async function connect(): Promise { currentTargetLabel = targetLabel(host, port, username); const pinnedKey = ui.fingerprint.value.trim() || hostKeys[currentTargetKey] || ''; currentExpectedFingerprint = pinnedKey; + currentRememberedFingerprint = ''; if (pinnedKey) ui.fingerprint.value = pinnedKey; currentInitialCommand = ui.initialCommand.value; initialCommandSent = false; @@ -1379,6 +1475,7 @@ async function connect(): Promise { socket = null; pendingHistory = null; currentExpectedFingerprint = ''; + currentRememberedFingerprint = ''; stopTimers(); clearHostKeyPrompt(); invalidateHistoryPasswordLoad(); @@ -1425,6 +1522,7 @@ function disconnect(reason = bilingual('已由用户断开连接', 'Disconnected clearHostKeyPrompt(); invalidateHistoryPasswordLoad(); currentExpectedFingerprint = ''; + currentRememberedFingerprint = ''; currentSessionSubtitle = messageTranslation(reason); ui.sessionSubtitle.textContent = reason; event(reason, 'disconnect'); diff --git a/frontend/src/style.css b/frontend/src/style.css index 22a4758..9092986 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1215,6 +1215,31 @@ button:disabled { text-align: center; } +.host-key-dialog.changed { + border-color: color-mix(in srgb, var(--danger) 48%, var(--border)); +} + +.host-key-dialog.changed .dialog-icon { + border-color: color-mix(in srgb, var(--danger) 52%, var(--border)); + background: var(--danger-dim); + color: var(--danger); +} + +.host-key-dialog.changed .eyebrow, +.host-key-dialog.changed #host-key-title { + color: var(--danger); +} + +.host-key-dialog.changed #host-key-expected-fingerprint { + color: var(--danger); +} + +.host-key-dialog.changed .dialog-actions .primary-button { + border-color: var(--danger); + background: var(--danger); + color: #fff; +} + .host-key-dialog h2 { margin: 0 0 7px; font-size: 18px; diff --git a/package.json b/package.json index ada3263..4d56a00 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "dev:web": "vite --config vite.config.ts", "build:web": "vite build --config vite.config.ts", "typecheck": "tsc -p tsconfig.worker.json --noEmit && tsc -p tsconfig.frontend.json --noEmit", - "check": "npm run typecheck && wrangler deploy --dry-run --outdir .wrangler/build", + "test": "node --test --experimental-strip-types tests/*.test.ts", + "check": "npm run typecheck && npm run test && wrangler deploy --dry-run --outdir .wrangler/build", "deploy": "wrangler deploy" }, "dependencies": { diff --git a/src/backend/session.ts b/src/backend/session.ts index 6efba77..cbba221 100644 --- a/src/backend/session.ts +++ b/src/backend/session.ts @@ -22,6 +22,7 @@ import { SSH_MSG_SERVICE_REQUEST, SSH_MSG_UNIMPLEMENTED, SSH_MSG_USERAUTH_FAILURE, + SSH_MSG_USERAUTH_INFO_REQUEST, SSH_MSG_USERAUTH_SUCCESS, type SSHConnectionConfig, type SSHPacket, @@ -42,6 +43,7 @@ import { KeyDerivation } from '../ssh/keys'; import { SSHAESCTRCipher, SSHAESGCMCipher, SSHHMAC } from '../ssh/crypto'; import { SSHAuth } from '../ssh/auth'; import { SSHChannel, type ChannelDataChunk } from '../ssh/channel'; +import { classifyHostKey } from '../ssh/host-key'; import { encodeString, readUint32, toBufferSource } from '../ssh/utils'; type Cipher = SSHAESGCMCipher | SSHAESCTRCipher; @@ -84,6 +86,7 @@ export class SSHSession { private verifier: SSHHMAC | null = null; private serverSigAlgs: string[] = []; private authRequestSent = false; + private passwordAuthMethod: 'password' | 'keyboard-interactive' = 'password'; private pendingChannelRequest: 'pty' | 'shell' | null = null; private ignoreNextKexPacket = false; private inputQueue: Uint8Array[] = []; @@ -157,6 +160,8 @@ export class SSHSession { this.queuedBytes = 0; this.pendingHostConfirmation?.resolve(false); this.pendingHostConfirmation = null; + this.config.password = undefined; + this.config.privateKey = undefined; try { this.writer?.releaseLock(); } catch { /* already released */ } this.writer = null; try { this.socket.close(); } catch { /* already closed */ } @@ -351,11 +356,11 @@ export class SSHSession { const keyType = this.decoder.decode(hostKey.subarray(4, 4 + keyTypeLength)); if (!this.isHostKeyAlgorithmCompatible(this.hostKeyAlgorithm, keyType)) throw new Error(`Server used ${keyType}, but negotiated ${this.hostKeyAlgorithm ?? 'no host key algorithm'}`); const fingerprint = `SHA256:${this.base64(new Uint8Array(await crypto.subtle.digest('SHA-256', toBufferSource(hostKey))))}`; - if (this.config.expectedFingerprint && this.config.expectedFingerprint !== fingerprint) throw new Error(`Host key mismatch: expected ${this.config.expectedFingerprint}, received ${fingerprint}`); if (!await this.verifyHostSignature(hostKey, signature, hash)) throw new Error('SSH host key signature verification failed'); - if (this.config.expectedFingerprint) { + const trust = classifyHostKey(this.config.expectedFingerprint, fingerprint); + if (trust === 'trusted') { this.sendJson({ type: 'host_key', fingerprint, keyType, trusted: true }); - } else if (!await this.confirmHostKey(fingerprint, keyType)) { + } else if (!await this.confirmHostKey(fingerprint, keyType, trust === 'changed' ? this.config.expectedFingerprint : undefined)) { throw new Error('Host key was not accepted'); } this.status('host_key_verified', `Host key verified (${keyType})`); @@ -380,7 +385,7 @@ export class SSHSession { await this.enableOutboundEncryption(); } - private async confirmHostKey(fingerprint: string, keyType: string): Promise { + private async confirmHostKey(fingerprint: string, keyType: string, expectedFingerprint?: string): Promise { this.phase = 'host-confirm'; this.status('host_key_confirmation', 'Confirm this host key before credentials are sent'); return new Promise((resolve) => { @@ -396,7 +401,7 @@ export class SSHSession { const timeout = setTimeout(() => finish(false), 30_000); this.pendingHostConfirmation = { fingerprint, resolve: finish }; // Register the decision handler before notifying the browser. - this.sendJson({ type: 'host_key', fingerprint, keyType, trusted: false }); + this.sendJson({ type: 'host_key', fingerprint, keyType, trusted: false, expectedFingerprint }); }); } @@ -477,14 +482,24 @@ export class SSHSession { ? await SSHAuth.buildPublicKeyAuthRequest(this.config.username, this.config.privateKey!, this.sessionId!, this.serverSigAlgs) : SSHAuth.buildPasswordAuthRequest(this.config.username, this.config.password!); this.authRequestSent = true; - this.config.password = undefined; - this.config.privateKey = undefined; + if (this.config.authMethod === 'publickey') this.config.privateKey = undefined; await this.sendEncrypted(request); return; } + if (type === SSH_MSG_USERAUTH_INFO_REQUEST) { + 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); + await this.sendEncrypted(response); + return; + } if (type === SSH_MSG_USERAUTH_SUCCESS) { if (payload.length !== 1) throw new Error('Malformed SSH authentication success'); if (!this.authRequestSent) throw new Error('SSH authentication completed before credentials were sent'); + this.config.password = undefined; + this.config.privateKey = undefined; this.status('auth_success', 'SSH authentication succeeded; opening terminal'); this.phase = 'pty'; this.startKeepalive(); @@ -493,7 +508,16 @@ export class SSHSession { } if (type === SSH_MSG_USERAUTH_FAILURE) { if (!this.authRequestSent) throw new Error('SSH authentication failed before credentials were sent'); - this.validateAuthFailure(payload); + const methods = this.validateAuthFailure(payload); + if (this.config.authMethod === 'password' + && this.passwordAuthMethod === 'password' + && this.config.password !== undefined + && methods.includes('keyboard-interactive')) { + this.passwordAuthMethod = 'keyboard-interactive'; + await this.sendEncrypted(SSHAuth.buildKeyboardInteractiveAuthRequest(this.config.username)); + return; + } + this.config.password = undefined; throw new Error('SSH authentication failed'); } } @@ -688,7 +712,11 @@ export class SSHSession { private readString(bytes: Uint8Array, offset: number): { value: string; next: number } { const field = this.readBytes(bytes, offset); - return { value: this.decoder.decode(field.value), next: field.next }; + try { + return { value: new TextDecoder('utf-8', { fatal: true }).decode(field.value), next: field.next }; + } catch { + throw new Error('Malformed SSH text field'); + } } private sshEcdsaToRaw(signature: Uint8Array, coordinateBytes: number): Uint8Array { @@ -738,12 +766,42 @@ export class SSHSession { if (offset !== payload.length) throw new Error('Malformed SSH debug message'); } - private validateAuthFailure(payload: Uint8Array): void { + private validateAuthFailure(payload: Uint8Array): string[] { if (payload.length < 6) throw new Error('Malformed SSH authentication failure'); const methodsLength = readUint32(payload, 1); if (methodsLength > payload.length - 6 || methodsLength + 6 !== payload.length) { throw new Error('Malformed SSH authentication failure'); } + const partialSuccess = payload[payload.length - 1]; + if (partialSuccess > 1) throw new Error('Malformed SSH authentication partial-success flag'); + if (partialSuccess === 1) throw new Error('Multi-factor SSH authentication is not supported'); + let methods: string; + try { methods = new TextDecoder('utf-8', { fatal: true }).decode(payload.subarray(5, 5 + methodsLength)); } + catch { throw new Error('Malformed SSH authentication methods'); } + if (methods && methods.split(',').some((method) => method.length === 0 || !/^[A-Za-z0-9@._+-]+$/.test(method))) { + throw new Error('Malformed SSH authentication methods'); + } + 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 { diff --git a/src/ssh/auth-password.ts b/src/ssh/auth-password.ts new file mode 100644 index 0000000..4eaaf31 --- /dev/null +++ b/src/ssh/auth-password.ts @@ -0,0 +1,54 @@ +const SSH_MSG_USERAUTH_REQUEST = 50; +const SSH_MSG_USERAUTH_INFO_RESPONSE = 61; + +function concat(...arrays: Uint8Array[]): Uint8Array { + const result = new Uint8Array(arrays.reduce((total, value) => total + value.length, 0)); + let offset = 0; + for (const value of arrays) { + result.set(value, offset); + offset += value.length; + } + return result; +} + +function encodeString(value: string): Uint8Array { + const data = new TextEncoder().encode(value); + const length = new Uint8Array(4); + new DataView(length.buffer).setUint32(0, data.length, false); + return concat(length, data); +} + +export function buildPasswordAuthRequest(username: string, password: string): Uint8Array { + return concat( + new Uint8Array([SSH_MSG_USERAUTH_REQUEST]), + encodeString(username), + encodeString('ssh-connection'), + encodeString('password'), + new Uint8Array([0x00]), + encodeString(password), + ); +} + +export function buildKeyboardInteractiveAuthRequest(username: string): Uint8Array { + return concat( + new Uint8Array([SSH_MSG_USERAUTH_REQUEST]), + encodeString(username), + encodeString('ssh-connection'), + encodeString('keyboard-interactive'), + encodeString(''), + encodeString(''), + ); +} + +export function buildKeyboardInteractiveResponse(password: string, promptCount: number): Uint8Array { + if (promptCount !== 1) throw new Error('SSH keyboard-interactive authentication requires exactly one prompt'); + return concat( + new Uint8Array([SSH_MSG_USERAUTH_INFO_RESPONSE]), + new Uint8Array([0, 0, 0, 1]), + encodeString(password), + ); +} + +export function isSupportedPasswordPrompt(prompt: string, echo: boolean): boolean { + return !echo && /(?:password|passphrase|密码|口令)/i.test(prompt); +} diff --git a/src/ssh/auth.ts b/src/ssh/auth.ts index 6065597..59fd821 100644 --- a/src/ssh/auth.ts +++ b/src/ssh/auth.ts @@ -1,5 +1,11 @@ import { SSH_MSG_USERAUTH_REQUEST } from '../types'; import { encodeString, concat, readUint32, toBufferSource } from './utils'; +import { + buildKeyboardInteractiveAuthRequest, + buildKeyboardInteractiveResponse, + buildPasswordAuthRequest, + isSupportedPasswordPrompt, +} from './auth-password'; // SSH key type constants const SSH_ED25519 = 'ssh-ed25519'; @@ -27,16 +33,19 @@ export class SSHAuth { username: string, password: string ): Uint8Array { - const parts: Uint8Array[] = [ - new Uint8Array([SSH_MSG_USERAUTH_REQUEST]), - encodeString(username), - encodeString('ssh-connection'), - encodeString('password'), - new Uint8Array([0x00]), - encodeString(password), - ]; + return buildPasswordAuthRequest(username, password); + } - return concat(...parts); + static buildKeyboardInteractiveAuthRequest(username: string): Uint8Array { + return buildKeyboardInteractiveAuthRequest(username); + } + + static buildKeyboardInteractiveResponse(password: string, promptCount: number): Uint8Array { + return buildKeyboardInteractiveResponse(password, promptCount); + } + + static isSupportedPasswordPrompt(prompt: string, echo: boolean): boolean { + return isSupportedPasswordPrompt(prompt, echo); } /** Build a signed public-key authentication request (RFC 4252 section 7). */ diff --git a/src/ssh/host-key.ts b/src/ssh/host-key.ts new file mode 100644 index 0000000..1c7b211 --- /dev/null +++ b/src/ssh/host-key.ts @@ -0,0 +1,10 @@ +export type HostKeyTrust = 'trusted' | 'first-seen' | 'changed'; + +export function classifyHostKey( + expectedFingerprint: string | undefined, + fingerprint: string, +): HostKeyTrust { + if (!expectedFingerprint) return 'first-seen'; + return expectedFingerprint === fingerprint ? 'trusted' : 'changed'; +} + diff --git a/src/types.ts b/src/types.ts index 89c9403..39c8268 100644 --- a/src/types.ts +++ b/src/types.ts @@ -66,6 +66,7 @@ export const SSH_MSG_KEX_ECDH_REPLY = 31; export const SSH_MSG_USERAUTH_REQUEST = 50; export const SSH_MSG_USERAUTH_FAILURE = 51; export const SSH_MSG_USERAUTH_SUCCESS = 52; +export const SSH_MSG_USERAUTH_INFO_REQUEST = 60; export const SSH_MSG_GLOBAL_REQUEST = 80; export const SSH_MSG_REQUEST_SUCCESS = 81; export const SSH_MSG_REQUEST_FAILURE = 82;