feat: 增强 SSH 主机密钥验证,支持键盘交互认证并优化 UI 提示

This commit is contained in:
cmliu committed 2026-07-28 22:19:52 +08:00
1 parent 663cf6abfb
commit c040c1b9fd
12 files changed
+325 -51

No files matched your search

+1
View File
@@ -7,3 +7,4 @@ coverage/
.env*
!.env.example
.workbuddy/
tests/
+2 -2
View File
@@ -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 |
+7 -9
View File
@@ -294,27 +294,25 @@
<dialog id="host-key-dialog" class="host-key-dialog" aria-labelledby="host-key-title">
<form method="dialog">
<span class="dialog-icon" aria-hidden="true" data-i18n-zh="密钥" data-i18n-en="KEY">密钥</span>
<span id="host-key-icon" class="dialog-icon" aria-hidden="true">密钥</span>
<div>
<p class="eyebrow" data-i18n-zh="主机身份" data-i18n-en="HOST IDENTITY">主机身份</p>
<h2 id="host-key-title" data-i18n-zh="信任此 SSH 主机?" data-i18n-en="Trust this SSH host?">信任此 SSH 主机?</h2>
<p
data-i18n-zh="服务器提供了尚未为此目标固定的密钥。继续前,请通过可信渠道核验。"
data-i18n-en="The server presented a key that has not been pinned for this target. Verify it through a trusted channel before continuing."
>服务器提供了尚未为此目标固定的密钥。继续前,请通过可信渠道核验。</p>
<p id="host-key-eyebrow" class="eyebrow">主机身份</p>
<h2 id="host-key-title">信任此 SSH 主机?</h2>
<p id="host-key-description">服务器提供了尚未为此目标固定的密钥。继续前,请通过可信渠道核验。</p>
</div>
<dl>
<div><dt data-i18n-zh="目标" data-i18n-en="Target">目标</dt><dd id="host-key-target">--</dd></div>
<div><dt data-i18n-zh="密钥类型" data-i18n-en="Key type">密钥类型</dt><dd id="host-key-type">--</dd></div>
<div id="host-key-expected-row" hidden><dt data-i18n-zh="之前的指纹" data-i18n-en="Previous fingerprint">之前的指纹</dt><dd id="host-key-expected-fingerprint">--</dd></div>
<div><dt data-i18n-zh="SHA-256 指纹" data-i18n-en="SHA-256 fingerprint">SHA-256 指纹</dt><dd id="host-key-fingerprint">--</dd></div>
</dl>
<label class="trust-check">
<input id="remember-host-key" type="checkbox" checked />
<span data-i18n-zh="下次记住并验证此指纹" data-i18n-en="Remember and verify this fingerprint next time">下次记住并验证此指纹</span>
<span id="remember-host-key-label">下次记住并验证此指纹</span>
</label>
<div class="dialog-actions">
<button id="reject-host-key" class="secondary-button" value="reject" type="submit" data-i18n-zh="取消连接" data-i18n-en="Cancel">取消连接</button>
<button id="accept-host-key" class="primary-button" value="accept" type="submit" data-i18n-zh="信任并继续" data-i18n-en="Trust &amp; continue">信任并继续</button>
<button id="accept-host-key" class="primary-button" value="accept" type="submit">信任并继续</button>
</div>
</form>
</dialog>
+19
View File
@@ -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';
}
+118 -20
View File
@@ -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<HTMLElement>('event-log'),
toastRegion: element<HTMLElement>('toast-region'),
hostKeyDialog: element<HTMLDialogElement>('host-key-dialog'),
hostKeyIcon: element<HTMLElement>('host-key-icon'),
hostKeyEyebrow: element<HTMLElement>('host-key-eyebrow'),
hostKeyTitle: element<HTMLElement>('host-key-title'),
hostKeyDescription: element<HTMLElement>('host-key-description'),
hostKeyTarget: element<HTMLElement>('host-key-target'),
hostKeyType: element<HTMLElement>('host-key-type'),
hostKeyExpectedRow: element<HTMLElement>('host-key-expected-row'),
hostKeyExpectedFingerprint: element<HTMLElement>('host-key-expected-fingerprint'),
hostKeyFingerprint: element<HTMLElement>('host-key-fingerprint'),
rememberHostKey: element<HTMLInputElement>('remember-host-key'),
rememberHostKeyLabel: element<HTMLElement>('remember-host-key-label'),
rejectHostKey: element<HTMLButtonElement>('reject-host-key'),
acceptHostKey: element<HTMLButtonElement>('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<string, string> {
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<Histor
}
}
function persistHostKeys(): void {
try { localStorage.setItem(HOST_KEY_STORAGE_KEY, JSON.stringify(hostKeys)); }
catch { toast(bilingual('此浏览器无法保存主机指纹。', 'Host fingerprints could not be saved in this browser.'), 'error'); }
function persistHostKeys(): boolean {
try {
localStorage.setItem(HOST_KEY_STORAGE_KEY, JSON.stringify(hostKeys));
return true;
} catch {
toast(bilingual('此浏览器无法保存主机指纹。', 'Host fingerprints could not be saved in this browser.'), 'error');
return false;
}
}
async function replaceRememberedHostKey(target: string, fingerprint: string): Promise<void> {
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<void> {
}
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<void> {
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<void> {
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');
+25
View File
@@ -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;
+2 -1
View File
@@ -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": {
+68 -10
View File
@@ -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<boolean> {
private async confirmHostKey(fingerprint: string, keyType: string, expectedFingerprint?: string): Promise<boolean> {
this.phase = 'host-confirm';
this.status('host_key_confirmation', 'Confirm this host key before credentials are sent');
return new Promise<boolean>((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 {
+54
View File
@@ -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);
}
+18 -9
View File
@@ -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). */
+10
View File
@@ -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';
}
+1
View File
@@ -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;